import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { app } from '@/scripts/app'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { NodeProgressState } from '@/schemas/apiSchema'

// Create mock functions that will be shared
const {
  mockNodeExecutionIdToNodeLocatorId,
  mockNodeIdToNodeLocatorId,
  mockNodeLocatorIdToNodeExecutionId,
  mockShowTextPreview
} = vi.hoisted(() => ({
  mockNodeExecutionIdToNodeLocatorId: vi.fn(),
  mockNodeIdToNodeLocatorId: vi.fn(),
  mockNodeLocatorIdToNodeExecutionId: vi.fn(),
  mockShowTextPreview: vi.fn()
}))
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'

// Mock the workflowStore
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
  const { ComfyWorkflow } = await vi.importActual<typeof WorkflowStoreModule>(
    '@/platform/workflow/management/stores/workflowStore'
  )
  return {
    ComfyWorkflow,
    useWorkflowStore: vi.fn(() => ({
      nodeExecutionIdToNodeLocatorId: mockNodeExecutionIdToNodeLocatorId,
      nodeIdToNodeLocatorId: mockNodeIdToNodeLocatorId,
      nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId
    }))
  }
})

// Remove any previous global types
declare global {
  interface Window {}
}

vi.mock('@/composables/node/useNodeProgressText', () => ({
  useNodeProgressText: () => ({
    showTextPreview: mockShowTextPreview
  })
}))

/**
 * Captures event handlers registered via api.addEventListener so tests
 * can invoke them directly (e.g. to simulate WebSocket progress events).
 */
type EventHandler = (...args: unknown[]) => void
const apiEventHandlers = new Map<string, EventHandler>()
vi.mock('@/scripts/api', () => ({
  api: {
    addEventListener: vi.fn((event: string, handler: EventHandler) => {
      apiEventHandlers.set(event, handler)
    }),
    removeEventListener: vi.fn((event: string) => {
      apiEventHandlers.delete(event)
    }),
    clientId: 'test-client',
    apiURL: vi.fn((path: string) => `/api${path}`)
  }
}))

vi.mock('@/stores/nodeOutputStore', () => ({
  useNodeOutputStore: () => ({
    revokePreviewsByExecutionId: vi.fn()
  })
}))

vi.mock('@/stores/jobPreviewStore', () => ({
  useJobPreviewStore: () => ({
    clearPreview: vi.fn()
  })
}))

// Mock the app import with proper implementation
vi.mock('@/scripts/app', () => ({
  app: {
    rootGraph: {
      getNodeById: vi.fn(),
      nodes: [] // Add nodes array for workflowStore iteration
    },
    revokePreviews: vi.fn(),
    nodePreviewImages: {}
  }
}))

function createQueuedWorkflow(path: string = 'workflows/test.json') {
  return {
    activeState: { id: 'workflow-id' },
    initialState: { id: 'workflow-id' },
    path
  } as Parameters<
    ReturnType<typeof useExecutionStore>['storeJob']
  >[0]['workflow']
}

function createPromptNode(title: string, classType: string) {
  return {
    inputs: {},
    class_type: classType,
    _meta: {
      title
    }
  }
}

describe('useExecutionStore - NodeLocatorId conversions', () => {
  let store: ReturnType<typeof useExecutionStore>

  beforeEach(() => {
    vi.clearAllMocks()
    // Reset mock implementations
    mockNodeExecutionIdToNodeLocatorId.mockReset()
    mockNodeIdToNodeLocatorId.mockReset()
    mockNodeLocatorIdToNodeExecutionId.mockReset()

    setActivePinia(createTestingPinia({ stubActions: false }))
    store = useExecutionStore()
  })

  describe('executionIdToNodeLocatorId', () => {
    it('should convert execution ID to NodeLocatorId', () => {
      // Mock subgraph structure
      const mockSubgraph = {
        id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
        nodes: []
      }

      const mockNode = createMockLGraphNode({
        id: 123,
        isSubgraphNode: () => true,
        subgraph: mockSubgraph
      })
      // Mock app.rootGraph.getNodeById to return the mock node
      vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)

      const result = executionIdToNodeLocatorId(app.rootGraph, '123:456')

      expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
    })

    it('should convert simple node ID to NodeLocatorId', () => {
      const result = executionIdToNodeLocatorId(app.rootGraph, '123')

      // For simple node IDs, it should return the ID as-is
      expect(result).toBe('123')
    })

    it('should handle numeric node IDs', () => {
      const result = executionIdToNodeLocatorId(app.rootGraph, 123)

      // For numeric IDs, it should convert to string and return as-is
      expect(result).toBe('123')
    })

    it('should return undefined when conversion fails', () => {
      // Mock app.rootGraph.getNodeById to return null (node not found)
      vi.mocked(app.rootGraph.getNodeById).mockReturnValue(null)

      expect(executionIdToNodeLocatorId(app.rootGraph, '999:456')).toBe(
        undefined
      )
    })
  })

  describe('nodeLocatorIdToExecutionId', () => {
    it('should convert NodeLocatorId to execution ID', () => {
      const mockExecutionId = '123:456'
      mockNodeLocatorIdToNodeExecutionId.mockReturnValue(mockExecutionId)

      const result = store.nodeLocatorIdToExecutionId(
        'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
      )

      expect(mockNodeLocatorIdToNodeExecutionId).toHaveBeenCalledWith(
        'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
      )
      expect(result).toBe(mockExecutionId)
    })

    it('should return null when conversion fails', () => {
      mockNodeLocatorIdToNodeExecutionId.mockReturnValue(null)

      const result = store.nodeLocatorIdToExecutionId('invalid:format')

      expect(result).toBeNull()
    })
  })
})

describe('useExecutionStore - nodeLocationProgressStates caching', () => {
  let store: ReturnType<typeof useExecutionStore>

  beforeEach(() => {
    vi.clearAllMocks()
    mockNodeExecutionIdToNodeLocatorId.mockReset()
    mockNodeIdToNodeLocatorId.mockReset()
    mockNodeLocatorIdToNodeExecutionId.mockReset()

    setActivePinia(createTestingPinia({ stubActions: false }))
    store = useExecutionStore()
  })

  it('should resolve execution IDs to locator IDs for subgraph nodes', () => {
    const mockSubgraph = {
      id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
      nodes: []
    }
    const mockNode = createMockLGraphNode({
      id: 123,
      isSubgraphNode: () => true,
      subgraph: mockSubgraph
    })
    vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)

    store.nodeProgressStates = {
      node1: {
        display_node_id: '123:456',
        state: 'running',
        value: 50,
        max: 100,
        prompt_id: 'test',
        node_id: 'node1'
      }
    }

    const result = store.nodeLocationProgressStates

    expect(result['123']).toBeDefined()
    expect(result['a1b2c3d4-e5f6-7890-abcd-ef1234567890:456']).toBeDefined()
  })

  it('should not re-traverse graph for same execution IDs across progress updates', () => {
    const mockSubgraph = {
      id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
      nodes: []
    }
    const mockNode = createMockLGraphNode({
      id: 123,
      isSubgraphNode: () => true,
      subgraph: mockSubgraph
    })
    vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)

    store.nodeProgressStates = {
      node1: {
        display_node_id: '123:456',
        state: 'running',
        value: 50,
        max: 100,
        prompt_id: 'test',
        node_id: 'node1'
      }
    }

    // First evaluation triggers graph traversal
    expect(store.nodeLocationProgressStates['123']).toBeDefined()
    const callCountAfterFirst = vi.mocked(app.rootGraph.getNodeById).mock.calls
      .length

    // Second update with same execution IDs but different progress
    store.nodeProgressStates = {
      node1: {
        display_node_id: '123:456',
        state: 'running',
        value: 75,
        max: 100,
        prompt_id: 'test',
        node_id: 'node1'
      }
    }

    expect(store.nodeLocationProgressStates['123']).toBeDefined()

    // getNodeById should NOT be called again for the same execution ID
    expect(vi.mocked(app.rootGraph.getNodeById).mock.calls.length).toBe(
      callCountAfterFirst
    )
  })

  it('should correctly resolve multiple sibling nodes in the same subgraph', () => {
    const mockSubgraph = {
      id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
      nodes: []
    }
    const mockNode = createMockLGraphNode({
      id: 123,
      isSubgraphNode: () => true,
      subgraph: mockSubgraph
    })
    vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)

    // Two sibling nodes in the same subgraph
    store.nodeProgressStates = {
      node1: {
        display_node_id: '123:456',
        state: 'running',
        value: 50,
        max: 100,
        prompt_id: 'test',
        node_id: 'node1'
      },
      node2: {
        display_node_id: '123:789',
        state: 'running',
        value: 30,
        max: 100,
        prompt_id: 'test',
        node_id: 'node2'
      }
    }

    const result = store.nodeLocationProgressStates

    // Both sibling nodes should be resolved with the correct subgraph UUID
    expect(result['a1b2c3d4-e5f6-7890-abcd-ef1234567890:456']).toBeDefined()
    expect(result['a1b2c3d4-e5f6-7890-abcd-ef1234567890:789']).toBeDefined()

    // The shared parent "123" should also have a merged state
    expect(result['123']).toBeDefined()
    expect(result['123'].state).toBe('running')
  })
})

describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
  let store: ReturnType<typeof useExecutionStore>

  function makeProgressNodes(
    nodeId: string,
    jobId: string
  ): Record<string, NodeProgressState> {
    return {
      [nodeId]: {
        value: 5,
        max: 10,
        state: 'running',
        node_id: nodeId,
        prompt_id: jobId,
        display_node_id: nodeId
      }
    }
  }

  function fireProgressState(
    jobId: string,
    nodes: Record<string, NodeProgressState>
  ) {
    const handler = apiEventHandlers.get('progress_state')
    if (!handler) throw new Error('progress_state handler not bound')
    handler(
      new CustomEvent('progress_state', { detail: { nodes, prompt_id: jobId } })
    )
  }

  beforeEach(() => {
    vi.clearAllMocks()
    apiEventHandlers.clear()
    setActivePinia(createTestingPinia({ stubActions: false }))
    store = useExecutionStore()
    store.bindExecutionEvents()
  })

  it('should retain entries below the limit', () => {
    for (let i = 0; i < 5; i++) {
      fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
    }

    expect(Object.keys(store.nodeProgressStatesByJob)).toHaveLength(5)
  })

  it('should evict oldest entries when exceeding MAX_PROGRESS_JOBS', () => {
    for (let i = 0; i < MAX_PROGRESS_JOBS + 10; i++) {
      fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
    }

    const keys = Object.keys(store.nodeProgressStatesByJob)
    expect(keys).toHaveLength(MAX_PROGRESS_JOBS)
    // Oldest jobs (0-9) should be evicted; newest should remain
    expect(keys).not.toContain('job-0')
    expect(keys).not.toContain('job-9')
    expect(keys).toContain(`job-${MAX_PROGRESS_JOBS + 9}`)
    expect(keys).toContain(`job-${MAX_PROGRESS_JOBS}`)
  })

  it('should keep the most recently added job after eviction', () => {
    for (let i = 0; i < MAX_PROGRESS_JOBS + 1; i++) {
      fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
    }

    const lastJobId = `job-${MAX_PROGRESS_JOBS}`
    expect(store.nodeProgressStatesByJob).toHaveProperty(lastJobId)
  })

  it('should not evict when updating an existing job', () => {
    for (let i = 0; i < MAX_PROGRESS_JOBS; i++) {
      fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
    }
    expect(Object.keys(store.nodeProgressStatesByJob)).toHaveLength(
      MAX_PROGRESS_JOBS
    )

    // Update an existing job — should not trigger eviction
    fireProgressState('job-0', makeProgressNodes('0', 'job-0'))
    expect(Object.keys(store.nodeProgressStatesByJob)).toHaveLength(
      MAX_PROGRESS_JOBS
    )
    expect(store.nodeProgressStatesByJob).toHaveProperty('job-0')
  })
})

describe('useExecutionStore - reconcileInitializingJobs', () => {
  let store: ReturnType<typeof useExecutionStore>

  beforeEach(() => {
    vi.clearAllMocks()
    setActivePinia(createTestingPinia({ stubActions: false }))
    store = useExecutionStore()
  })

  it('should remove job IDs not present in active jobs', () => {
    store.initializingJobIds = new Set(['job-1', 'job-2', 'job-3'])

    store.reconcileInitializingJobs(new Set(['job-1']))

    expect(store.initializingJobIds).toEqual(new Set(['job-1']))
  })

  it('should be a no-op when all initializing IDs are active', () => {
    store.initializingJobIds = new Set(['job-1', 'job-2'])

    store.reconcileInitializingJobs(new Set(['job-1', 'job-2', 'job-3']))

    expect(store.initializingJobIds).toEqual(new Set(['job-1', 'job-2']))
  })

  it('should be a no-op when there are no initializing jobs', () => {
    store.initializingJobIds = new Set()

    store.reconcileInitializingJobs(new Set(['job-1']))

    expect(store.initializingJobIds).toEqual(new Set())
  })

  it('should clear all initializing IDs when no active jobs exist', () => {
    store.initializingJobIds = new Set(['job-1', 'job-2'])

    store.reconcileInitializingJobs(new Set())

    expect(store.initializingJobIds).toEqual(new Set())
  })
})

describe('useExecutionStore - clearActiveJobIfStale', () => {
  let store: ReturnType<typeof useExecutionStore>

  beforeEach(() => {
    vi.clearAllMocks()
    setActivePinia(createTestingPinia({ stubActions: false }))
    store = useExecutionStore()
  })

  it('clears the active job and progress state when not in the active set', () => {
    store.activeJobId = 'job-1'
    store.queuedJobs = { 'job-1': { nodes: { 'node-1': false } } }
    store.nodeProgressStates = {
      'node-1': {
        value: 5,
        max: 10,
        state: 'running',
        node_id: 'node-1',
        display_node_id: 'node-1',
        prompt_id: 'job-1'
      }
    }

    store.clearActiveJobIfStale(new Set(['job-2']))

    expect(store.activeJobId).toBeNull()
    expect(store.queuedJobs['job-1']).toBeUndefined()
    expect(store.nodeProgressStates).toEqual({})
  })

  it('preserves the active job when present in the active set', () => {
    store.activeJobId = 'job-1'
    store.queuedJobs = { 'job-1': { nodes: {} } }

    store.clearActiveJobIfStale(new Set(['job-1', 'job-2']))

    expect(store.activeJobId).toBe('job-1')
    expect(store.queuedJobs['job-1']).toBeDefined()
  })

  it('is a no-op when there is no active job', () => {
    store.activeJobId = null
    store.queuedJobs = { other: { nodes: {} } }

    store.clearActiveJobIfStale(new Set())

    expect(store.activeJobId).toBeNull()
    expect(store.queuedJobs['other']).toBeDefined()
  })
})

describe('useExecutionStore - progress_text startup guard', () => {
  let store: ReturnType<typeof useExecutionStore>

  function fireProgressText(detail: {
    nodeId: string
    text: string
    prompt_id?: string
  }) {
    const handler = apiEventHandlers.get('progress_text')
    if (!handler) throw new Error('progress_text handler not bound')
    handler(new CustomEvent('progress_text', { detail }))
  }

  beforeEach(() => {
    vi.clearAllMocks()
    apiEventHandlers.clear()
    setActivePinia(createTestingPinia({ stubActions: false }))
    store = useExecutionStore()
    store.bindExecutionEvents()
  })

  it('should ignore progress_text before the canvas is initialized', async () => {
    const { useCanvasStore } =
      await import('@/renderer/core/canvas/canvasStore')
    useCanvasStore().canvas = null

    expect(() =>
      fireProgressText({
        nodeId: '1',
        text: 'warming up'
      })
    ).not.toThrow()

    expect(mockShowTextPreview).not.toHaveBeenCalled()
  })

  it('should call showTextPreview when canvas is available', async () => {
    const mockNode = createMockLGraphNode({ id: 1 })
    const { useCanvasStore } =
      await import('@/renderer/core/canvas/canvasStore')
    useCanvasStore().canvas = {
      graph: { getNodeById: vi.fn(() => mockNode) }
    } as unknown as LGraphCanvas

    fireProgressText({ nodeId: '1', text: 'warming up' })

    expect(mockShowTextPreview).toHaveBeenCalledWith(mockNode, 'warming up')
  })
})

describe('useExecutionErrorStore - Node Error Lookups', () => {
  let store: ReturnType<typeof useExecutionErrorStore>

  beforeEach(() => {
    vi.clearAllMocks()
    setActivePinia(createTestingPinia({ stubActions: false }))
    store = useExecutionErrorStore()
  })

  describe('getNodeErrors', () => {
    it('should return undefined when no errors exist', () => {
      const result = store.getNodeErrors('123')
      expect(result).toBeUndefined()
    })

    it('should return node error by locator ID for root graph node', () => {
      store.lastNodeErrors = {
        '123': {
          errors: [
            {
              type: 'validation_error',
              message: 'Invalid input',
              details: 'Width must be positive',
              extra_info: { input_name: 'width' }
            }
          ],
          class_type: 'TestNode',
          dependent_outputs: []
        }
      }

      const result = store.getNodeErrors('123')
      expect(result).toBeDefined()
      expect(result?.errors).toHaveLength(1)
      expect(result?.errors[0].message).toBe('Invalid input')
    })

    it('should return node error by locator ID for subgraph node', () => {
      const subgraphUuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
      const mockSubgraph = {
        id: subgraphUuid,
        getNodeById: vi.fn(),
        nodes: []
      }

      const mockNode = createMockLGraphNode({
        id: 123,
        isSubgraphNode: () => true,
        subgraph: mockSubgraph
      })

      vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)

      store.lastNodeErrors = {
        '123:456': {
          errors: [
            {
              type: 'validation_error',
              message: 'Invalid subgraph input',
              details: 'Missing required input',
              extra_info: { input_name: 'image' }
            }
          ],
          class_type: 'SubgraphNode',
          dependent_outputs: []
        }
      }

      const locatorId = `${subgraphUuid}:456`
      const result = store.getNodeErrors(locatorId)
      expect(result).toBeDefined()
      expect(result?.errors[0].message).toBe('Invalid subgraph input')
    })
  })

  describe('slotHasError', () => {
    it('should return false when node has no errors', () => {
      const result = store.slotHasError('123', 'width')
      expect(result).toBe(false)
    })

    it('should return false when node has errors but slot is not mentioned', () => {
      store.lastNodeErrors = {
        '123': {
          errors: [
            {
              type: 'validation_error',
              message: 'Invalid input',
              details: 'Width must be positive',
              extra_info: { input_name: 'width' }
            }
          ],
          class_type: 'TestNode',
          dependent_outputs: []
        }
      }

      const result = store.slotHasError('123', 'height')
      expect(result).toBe(false)
    })

    it('should return true when slot has error', () => {
      store.lastNodeErrors = {
        '123': {
          errors: [
            {
              type: 'validation_error',
              message: 'Invalid input',
              details: 'Width must be positive',
              extra_info: { input_name: 'width' }
            }
          ],
          class_type: 'TestNode',
          dependent_outputs: []
        }
      }

      const result = store.slotHasError('123', 'width')
      expect(result).toBe(true)
    })

    it('should return true when multiple errors exist for the same slot', () => {
      store.lastNodeErrors = {
        '123': {
          errors: [
            {
              type: 'validation_error',
              message: 'Invalid input',
              details: 'Width must be positive',
              extra_info: { input_name: 'width' }
            },
            {
              type: 'validation_error',
              message: 'Invalid range',
              details: 'Width must be less than 1000',
              extra_info: { input_name: 'width' }
            }
          ],
          class_type: 'TestNode',
          dependent_outputs: []
        }
      }

      const result = store.slotHasError('123', 'width')
      expect(result).toBe(true)
    })

    it('should handle errors without extra_info', () => {
      store.lastNodeErrors = {
        '123': {
          errors: [
            {
              type: 'validation_error',
              message: 'General error',
              details: 'Something went wrong'
            }
          ],
          class_type: 'TestNode',
          dependent_outputs: []
        }
      }

      const result = store.slotHasError('123', 'width')
      expect(result).toBe(false)
    })
  })
})

describe('useExecutionStore - executingNode with subgraphs', () => {
  let store: ReturnType<typeof useExecutionStore>

  beforeEach(() => {
    vi.clearAllMocks()
    setActivePinia(createTestingPinia({ stubActions: false }))
    store = useExecutionStore()
  })

  it('should find executing node info in root graph from queued prompt data', () => {
    store.storeJob({
      id: 'test-prompt',
      nodes: ['123'],
      promptOutput: {
        '123': createPromptNode('Test Node', 'TestNode')
      },
      workflow: createQueuedWorkflow()
    })
    store.activeJobId = 'test-prompt'

    store.nodeProgressStates = {
      '123': {
        state: 'running',
        value: 0,
        max: 100,
        display_node_id: '123',
        prompt_id: 'test-prompt',
        node_id: '123'
      }
    }

    expect(store.executingNode).toEqual({
      title: 'Test Node',
      type: 'TestNode'
    })
  })

  it('should find executing node info in subgraph using execution ID', () => {
    store.storeJob({
      id: 'test-prompt',
      nodes: ['456:789'],
      promptOutput: {
        '456:789': createPromptNode('Nested Node', 'NestedNode')
      },
      workflow: createQueuedWorkflow()
    })
    store.activeJobId = 'test-prompt'

    store.nodeProgressStates = {
      '456:789': {
        state: 'running',
        value: 0,
        max: 100,
        display_node_id: '456:789',
        prompt_id: 'test-prompt',
        node_id: '456:789'
      }
    }

    expect(store.executingNode).toEqual({
      title: 'Nested Node',
      type: 'NestedNode'
    })
  })

  it('should return null when no node is executing', () => {
    store.nodeProgressStates = {}

    expect(store.executingNode).toBeNull()
  })

  it('should return null when executing node metadata cannot be found', () => {
    store.storeJob({
      id: 'test-prompt',
      nodes: ['123'],
      promptOutput: {
        '123': createPromptNode('Test Node', 'TestNode')
      },
      workflow: createQueuedWorkflow()
    })
    store.activeJobId = 'test-prompt'

    store.nodeProgressStates = {
      '999': {
        state: 'running',
        value: 0,
        max: 100,
        display_node_id: '999',
        prompt_id: 'test-prompt',
        node_id: '999'
      }
    }

    expect(store.executingNode).toBeNull()
  })
})

describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
  let store: ReturnType<typeof useMissingNodesErrorStore>

  beforeEach(() => {
    vi.clearAllMocks()
    setActivePinia(createTestingPinia({ stubActions: false }))
    store = useMissingNodesErrorStore()
  })

  it('clears missingNodesError when called with an empty array', () => {
    store.setMissingNodeTypes([{ type: 'NodeA' }])
    store.setMissingNodeTypes([])
    expect(store.missingNodesError).toBeNull()
  })

  it('hasMissingNodes is false when error is null', () => {
    store.setMissingNodeTypes([])
    expect(store.hasMissingNodes).toBe(false)
  })

  it('hasMissingNodes is true after setting non-empty types', () => {
    store.setMissingNodeTypes([{ type: 'NodeA' }])
    expect(store.hasMissingNodes).toBe(true)
  })

  it('deduplicates string entries by value', () => {
    store.setMissingNodeTypes(['GroupNode', 'GroupNode', 'OtherGroup'])
    expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
    expect(store.missingNodesError?.nodeTypes).toEqual([
      'GroupNode',
      'OtherGroup'
    ])
  })

  it('keeps a single string entry unchanged', () => {
    store.setMissingNodeTypes(['GroupNode'])
    expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
  })

  it('deduplicates object entries with the same nodeId', () => {
    store.setMissingNodeTypes([
      { type: 'NodeA', nodeId: 1 },
      { type: 'NodeA', nodeId: 1 }
    ])
    expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
  })

  it('keeps object entries with different nodeIds even if same type', () => {
    store.setMissingNodeTypes([
      { type: 'NodeA', nodeId: 1 },
      { type: 'NodeA', nodeId: 2 }
    ])
    expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
  })

  it('deduplicates object entries by type when nodeId is absent', () => {
    store.setMissingNodeTypes([{ type: 'NodeB' }, { type: 'NodeB' }])
    expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
  })

  it('keeps distinct types when nodeId is absent', () => {
    store.setMissingNodeTypes([{ type: 'NodeB' }, { type: 'NodeC' }])
    expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
  })

  it('treats absent nodeId the same as type-only key (falls back to type)', () => {
    store.setMissingNodeTypes([{ type: 'NodeD' }, { type: 'NodeD' }])
    expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
  })

  it('handles a mix of string and object entries correctly', () => {
    store.setMissingNodeTypes([
      'GroupNode',
      'GroupNode', // string dup
      { type: 'NodeA', nodeId: 1 },
      { type: 'NodeA', nodeId: 1 }, // object dup by nodeId
      { type: 'NodeA', nodeId: 2 }, // same type, different nodeId → kept
      { type: 'NodeB' },
      { type: 'NodeB' } // object dup by type
    ])
    // Unique: 'GroupNode', {NodeA,1}, {NodeA,2}, {NodeB} → 4
    expect(store.missingNodesError?.nodeTypes).toHaveLength(4)
  })

  it('stores a non-empty message string in missingNodesError', () => {
    store.setMissingNodeTypes([{ type: 'NodeA' }])
    expect(typeof store.missingNodesError?.message).toBe('string')
    expect(store.missingNodesError!.message.length).toBeGreaterThan(0)
  })

  it('stores the deduplicated nodeTypes array in missingNodesError', () => {
    const input = [{ type: 'NodeA' }, { type: 'NodeB' }]
    store.setMissingNodeTypes(input)
    expect(store.missingNodesError?.nodeTypes).toEqual(input)
  })
})

describe('useExecutionStore - WebSocket event handlers', () => {
  let store: ReturnType<typeof useExecutionStore>

  function fire<T>(event: string, detail: T) {
    const handler = apiEventHandlers.get(event)
    if (!handler) throw new Error(`${event} handler not bound`)
    handler(new CustomEvent(event, { detail }))
  }

  beforeEach(() => {
    vi.clearAllMocks()
    apiEventHandlers.clear()
    setActivePinia(createTestingPinia({ stubActions: false }))
    store = useExecutionStore()
    store.bindExecutionEvents()
  })

  describe('execution_start', () => {
    it('sets activeJobId and seeds an empty queued job entry', () => {
      fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })

      expect(store.activeJobId).toBe('job-1')
      expect(store.queuedJobs['job-1']).toEqual({ nodes: {} })
    })

    it('clears transient errors while preserving validation errors', () => {
      const errorStore = useExecutionErrorStore()
      const nodeErrors = {
        '1': {
          class_type: 'Test',
          dependent_outputs: [],
          errors: [
            {
              type: 'required_input_missing',
              message: 'Missing',
              details: '',
              extra_info: { input_name: 'x' }
            }
          ]
        }
      }
      errorStore.lastExecutionError = {
        prompt_id: 'old-job',
        timestamp: 0,
        node_id: '1',
        node_type: 'Test',
        executed: [],
        exception_message: 'boom',
        exception_type: 'RuntimeError',
        traceback: []
      }
      errorStore.lastPromptError = {
        type: 'old-error',
        message: 'old prompt error',
        details: ''
      }
      errorStore.lastNodeErrors = nodeErrors
      errorStore.showErrorOverlay()

      fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })

      expect(errorStore.lastExecutionError).toBeNull()
      expect(errorStore.lastPromptError).toBeNull()
      expect(errorStore.lastNodeErrors).toEqual(nodeErrors)
      expect(errorStore.isErrorOverlayOpen).toBe(true)
    })

    it('clears initializing state for the starting job', () => {
      store.initializingJobIds = new Set([
        'job-1',
        'job-2'
      ]) as unknown as Set<string>
      fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })

      expect(store.initializingJobIds.has('job-1')).toBe(false)
      expect(store.initializingJobIds.has('job-2')).toBe(true)
    })
  })

  describe('execution_cached', () => {
    it('marks the listed nodes as cached on the active job', () => {
      fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })

      fire('execution_cached', {
        prompt_id: 'job-1',
        nodes: ['nodeA', 'nodeB'],
        timestamp: 0
      })

      expect(store.activeJob?.nodes).toEqual({ nodeA: true, nodeB: true })
    })

    it('is a no-op when no active job exists', () => {
      fire('execution_cached', {
        prompt_id: 'job-1',
        nodes: ['nodeA'],
        timestamp: 0
      })

      expect(store.activeJob).toBeUndefined()
    })
  })

  describe('execution_interrupted', () => {
    it('clears active job state on interrupt', () => {
      fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
      expect(store.activeJobId).toBe('job-1')

      fire('execution_interrupted', {
        prompt_id: 'job-1',
        node_id: 'n1',
        node_type: 't',
        executed: [],
        timestamp: 0
      })

      expect(store.activeJobId).toBeNull()
      expect(store.queuedJobs['job-1']).toBeUndefined()
    })
  })

  describe('executed', () => {
    it('marks the executed node as done on the active job', () => {
      fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
      fire('execution_cached', {
        prompt_id: 'job-1',
        nodes: ['n1'],
        timestamp: 0
      })

      fire('executed', {
        node: 'n1',
        display_node: 'n1',
        prompt_id: 'job-1',
        output: {}
      })

      expect(store.activeJob?.nodes['n1']).toBe(true)
    })

    it('is a no-op when no active job exists', () => {
      expect(() =>
        fire('executed', {
          node: 'n1',
          display_node: 'n1',
          prompt_id: 'orphan',
          output: {}
        })
      ).not.toThrow()
      expect(store.activeJob).toBeUndefined()
    })
  })

  describe('execution_success', () => {
    it('clears active job and progress state', () => {
      fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })

      fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })

      expect(store.activeJobId).toBeNull()
      expect(store.queuedJobs['job-1']).toBeUndefined()
    })
  })

  describe('executing', () => {
    it('clears _executingNodeProgress and activeJobId when detail is null', () => {
      fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
      store._executingNodeProgress = {
        value: 1,
        max: 2,
        prompt_id: 'job-1',
        node: '1'
      }

      fire('executing', null)

      expect(store._executingNodeProgress).toBeNull()
      expect(store.activeJobId).toBeNull()
    })
  })

  describe('progress', () => {
    it('sets _executingNodeProgress from the event payload', () => {
      const payload = { value: 3, max: 10, prompt_id: 'job-1', node: 'n1' }

      fire('progress', payload)

      expect(store._executingNodeProgress).toEqual(payload)
    })
  })

  describe('status', () => {
    it('reads clientId from api once and stops listening', async () => {
      const apiModule = await import('@/scripts/api')
      const removeSpy = vi.mocked(apiModule.api.removeEventListener)

      fire('status', { exec_info: { queue_remaining: 0 } })

      expect(store.clientId).toBe('test-client')
      expect(removeSpy).toHaveBeenCalledWith('status', expect.any(Function))
    })
  })

  describe('execution_error', () => {
    it('routes a service-level error (no node_id) to the prompt error store', () => {
      const errorStore = useExecutionErrorStore()

      fire('execution_error', {
        prompt_id: 'job-1',
        node_id: null,
        exception_type: 'StagnationError',
        exception_message: 'Job has stagnated',
        traceback: ['line 1', 'line 2']
      })

      expect(errorStore.lastPromptError).toMatchObject({
        type: 'StagnationError',
        message: 'StagnationError: Job has stagnated',
        details: 'line 1\nline 2'
      })
    })

    it('routes a runtime error (with node_id) to lastExecutionError', () => {
      const errorStore = useExecutionErrorStore()

      fire('execution_error', {
        prompt_id: 'job-1',
        node_id: 'n1',
        node_type: 'KSampler',
        exception_type: 'RuntimeError',
        exception_message: 'CUDA OOM',
        traceback: []
      })

      expect(errorStore.lastExecutionError).toMatchObject({
        prompt_id: 'job-1',
        node_id: 'n1',
        exception_message: 'CUDA OOM'
      })
    })
  })

  describe('notification', () => {
    it('marks a job as initializing when text indicates waiting for a machine', () => {
      fire('notification', {
        id: 'job-9',
        value: 'Waiting for a machine to become available'
      })

      expect(store.initializingJobIds.has('job-9')).toBe(true)
    })

    it('ignores notifications without an id', () => {
      fire('notification', {
        id: '',
        value: 'Waiting for a machine'
      })

      expect(store.initializingJobIds.size).toBe(0)
    })

    it('ignores notifications without the waiting-for-machine sentinel', () => {
      fire('notification', { id: 'job-9', value: 'Hello' })

      expect(store.initializingJobIds.has('job-9')).toBe(false)
    })
  })

  describe('unbindExecutionEvents', () => {
    it('removes every listener registered by bindExecutionEvents', async () => {
      const apiModule = await import('@/scripts/api')
      const removeSpy = vi.mocked(apiModule.api.removeEventListener)
      const events = [
        'notification',
        'execution_start',
        'execution_cached',
        'execution_interrupted',
        'execution_success',
        'executed',
        'executing',
        'progress',
        'progress_state',
        'execution_error',
        'progress_text'
      ]

      store.unbindExecutionEvents()

      for (const event of events) {
        expect(removeSpy).toHaveBeenCalledWith(event, expect.any(Function))
      }
    })
  })
})

describe('useExecutionStore - storeJob and workflow path tracking', () => {
  let store: ReturnType<typeof useExecutionStore>

  beforeEach(() => {
    vi.clearAllMocks()
    apiEventHandlers.clear()
    setActivePinia(createTestingPinia({ stubActions: false }))
    store = useExecutionStore()
  })

  it('storeJob populates queuedJobs and tracks the workflow path', () => {
    const workflow = {
      activeState: { id: 'wf-1' },
      initialState: { id: 'wf-1' },
      path: '/workflows/foo.json'
    } as unknown as Parameters<typeof store.storeJob>[0]['workflow']

    store.storeJob({
      nodes: ['a', 'b'],
      id: 'job-1',
      promptOutput: {
        a: createPromptNode('Node A', 'NodeA'),
        b: createPromptNode('Node B', 'NodeB')
      },
      workflow
    })

    expect(store.queuedJobs['job-1']?.nodes).toEqual({ a: false, b: false })
    expect(store.queuedJobs['job-1']?.nodeLookup).toEqual({
      a: { title: 'Node A', type: 'NodeA' },
      b: { title: 'Node B', type: 'NodeB' }
    })
    expect(store.queuedJobs['job-1']?.workflow).toStrictEqual(workflow)
    expect(store.jobIdToWorkflowId.get('job-1')).toBe('wf-1')
    expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe(
      '/workflows/foo.json'
    )
  })

  it('registerJobWorkflowIdMapping ignores empty inputs', () => {
    store.registerJobWorkflowIdMapping('job-1', 'wf-1')
    store.registerJobWorkflowIdMapping('', 'wf-2')
    store.registerJobWorkflowIdMapping('job-2', '')

    expect(store.jobIdToWorkflowId.get('job-1')).toBe('wf-1')
    expect(store.jobIdToWorkflowId.size).toBe(1)
  })

  it('ensureSessionWorkflowPath is idempotent and updates on change', () => {
    store.ensureSessionWorkflowPath('job-1', '/a.json')
    store.ensureSessionWorkflowPath('job-1', '/a.json')
    store.ensureSessionWorkflowPath('job-1', '/b.json')

    expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe('/b.json')
  })
})
