import { fromAny } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import type { MissingNodeType } from '@/types/comfy'

vi.mock('@/scripts/app', () => ({
  app: {
    rootGraph: {
      serialize: vi.fn(() => ({})),
      getNodeById: vi.fn()
    }
  }
}))

vi.mock('@/utils/graphTraversalUtil', () => ({
  getNodeByExecutionId: vi.fn(),
  getExecutionIdByNode: vi.fn(),
  getRootParentNode: vi.fn(() => null),
  forEachNode: vi.fn(),
  mapAllNodes: vi.fn(() => [])
}))

const mockIsCloud = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/distribution/types', () => ({
  get isCloud() {
    return mockIsCloud.value
  }
}))

vi.mock('@/i18n', () => {
  const messages: Record<string, string> = {
    'errorCatalog.validationErrors.required_input_missing.title':
      'Missing connection',
    'errorCatalog.validationErrors.required_input_missing.message':
      'Required input slots have no connection feeding them.',
    'errorCatalog.validationErrors.required_input_missing.details':
      '{nodeName} is missing a required input: {inputName}',
    'errorCatalog.validationErrors.required_input_missing.itemLabel':
      '{nodeName} - {inputName}',
    'errorCatalog.validationErrors.required_input_missing.toastTitle':
      'Required input missing',
    'errorCatalog.validationErrors.required_input_missing.toastMessage':
      '{nodeName} is missing a required input: {inputName}',
    'errorCatalog.promptErrors.prompt_no_outputs.title':
      'Prompt has no outputs',
    'errorCatalog.promptErrors.prompt_no_outputs.desc':
      'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.',
    'errorCatalog.runtimeErrors.execution_failed.title': 'Execution failed',
    'errorCatalog.runtimeErrors.execution_failed.message':
      'Node threw an error during execution.',
    'errorCatalog.runtimeErrors.execution_failed.itemLabel': '{nodeName}',
    'errorCatalog.runtimeErrors.execution_failed.toastTitle':
      '{nodeName} failed',
    'errorCatalog.runtimeErrors.execution_failed.toastMessage':
      'This node threw an error during execution. Check its inputs or try a different configuration.',
    'errorCatalog.runtimeErrors.out_of_memory.title': 'Generation failed',
    'errorCatalog.runtimeErrors.out_of_memory.message':
      'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
    'errorCatalog.runtimeErrors.out_of_memory.itemLabel': '{nodeName}',
    'errorCatalog.runtimeErrors.out_of_memory.toastTitle': 'Generation failed',
    'errorCatalog.runtimeErrors.out_of_memory.toastMessage':
      'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
  }

  const interpolate = (
    message: string,
    params?: Record<string, string | number>
  ) =>
    message.replace(/\{(\w+)\}/g, (match, paramName) =>
      params?.[paramName] === undefined ? match : String(params[paramName])
    )

  return {
    te: vi.fn((key: string) => key in messages),
    st: vi.fn((key: string, fallback: string) => messages[key] ?? fallback),
    t: vi.fn((key: string, params?: Record<string, string | number>) => {
      if (key === 'errorOverlay.missingModels') {
        const count = Number(params?.count ?? 0)
        return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
      }

      return interpolate(messages[key] ?? key, params)
    })
  }
})

vi.mock('@/stores/comfyRegistryStore', () => ({
  useComfyRegistryStore: () => ({
    inferPackFromNodeName: vi.fn()
  })
}))

vi.mock('@/utils/nodeTitleUtil', () => ({
  resolveNodeDisplayName: vi.fn(() => '')
}))

vi.mock('@/utils/litegraphUtil', () => ({
  isLGraphNode: vi.fn(() => false)
}))

vi.mock('@/utils/executableGroupNodeDto', () => ({
  isGroupNode: vi.fn(() => false)
}))

vi.mock(
  '@/platform/missingModel/composables/useMissingModelInteractions',
  () => ({
    clearMissingModelState: vi.fn()
  })
)

import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { useErrorGroups } from './useErrorGroups'

function makeMissingNodeType(
  type: string,
  opts: {
    nodeId?: string
    isReplaceable?: boolean
    cnrId?: string
    replacement?: { new_node_id: string }
  } = {}
): MissingNodeType {
  return {
    type,
    nodeId: opts.nodeId ?? '1',
    isReplaceable: opts.isReplaceable ?? false,
    cnrId: opts.cnrId,
    replacement: opts.replacement
      ? {
          old_node_id: type,
          new_node_id: opts.replacement.new_node_id,
          old_widget_ids: null,
          input_mapping: null,
          output_mapping: null
        }
      : undefined
  }
}

function makeModel(
  name: string,
  opts: {
    nodeId?: string | number
    widgetName?: string
    directory?: string
    isAssetSupported?: boolean
  } = {}
) {
  return {
    name,
    nodeId: opts.nodeId ?? '1',
    nodeType: 'CheckpointLoaderSimple',
    widgetName: opts.widgetName ?? 'ckpt_name',
    isAssetSupported: opts.isAssetSupported ?? false,
    isMissing: true as const,
    directory: opts.directory
  }
}

function createErrorGroups() {
  const store = useExecutionErrorStore()
  const searchQuery = ref('')
  const groups = useErrorGroups(searchQuery)
  return { store, searchQuery, groups }
}

describe('useErrorGroups', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    mockIsCloud.value = false
    vi.mocked(isLGraphNode).mockReturnValue(false)
  })

  describe('missingPackGroups', () => {
    it('returns empty array when no missing nodes', () => {
      const { groups } = createErrorGroups()
      expect(groups.missingPackGroups.value).toEqual([])
    })

    it('groups non-replaceable nodes by cnrId', async () => {
      const { groups } = createErrorGroups()
      const missingNodesStore = useMissingNodesErrorStore()
      missingNodesStore.setMissingNodeTypes([
        makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
        makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
        makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
      ])
      await nextTick()

      expect(groups.missingPackGroups.value).toHaveLength(2)
      const pack1 = groups.missingPackGroups.value.find(
        (g) => g.packId === 'pack-1'
      )
      expect(pack1?.nodeTypes).toHaveLength(2)
      const pack2 = groups.missingPackGroups.value.find(
        (g) => g.packId === 'pack-2'
      )
      expect(pack2?.nodeTypes).toHaveLength(1)
    })

    it('excludes replaceable nodes from missingPackGroups', async () => {
      const { groups } = createErrorGroups()
      const missingNodesStore = useMissingNodesErrorStore()
      missingNodesStore.setMissingNodeTypes([
        makeMissingNodeType('OldNode', {
          isReplaceable: true,
          replacement: { new_node_id: 'NewNode' }
        }),
        makeMissingNodeType('MissingNode', {
          nodeId: '2',
          cnrId: 'some-pack'
        })
      ])
      await nextTick()

      expect(groups.missingPackGroups.value).toHaveLength(1)
      expect(groups.missingPackGroups.value[0].packId).toBe('some-pack')
    })

    it('groups nodes without cnrId under null packId', async () => {
      const { groups } = createErrorGroups()
      const missingNodesStore = useMissingNodesErrorStore()
      missingNodesStore.setMissingNodeTypes([
        makeMissingNodeType('UnknownNode', { nodeId: '1' }),
        makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
      ])
      await nextTick()

      expect(groups.missingPackGroups.value).toHaveLength(1)
      expect(groups.missingPackGroups.value[0].packId).toBeNull()
      expect(groups.missingPackGroups.value[0].nodeTypes).toHaveLength(2)
    })

    it('sorts groups alphabetically with null packId last', async () => {
      const { groups } = createErrorGroups()
      const missingNodesStore = useMissingNodesErrorStore()
      missingNodesStore.setMissingNodeTypes([
        makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
        makeMissingNodeType('NodeB', { nodeId: '2' }),
        makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
      ])
      await nextTick()

      const packIds = groups.missingPackGroups.value.map((g) => g.packId)
      expect(packIds).toEqual(['alpha-pack', 'zebra-pack', null])
    })

    it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
      const { groups } = createErrorGroups()
      const missingNodesStore = useMissingNodesErrorStore()
      missingNodesStore.setMissingNodeTypes([
        makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
        makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
        makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
      ])
      await nextTick()

      const group = groups.missingPackGroups.value[0]
      const types = group.nodeTypes.map((n) =>
        typeof n === 'string' ? n : `${n.type}:${n.nodeId}`
      )
      expect(types).toEqual(['NodeA:1', 'NodeA:3', 'NodeB:2'])
    })

    it('handles string nodeType entries', async () => {
      const { groups } = createErrorGroups()
      const missingNodesStore = useMissingNodesErrorStore()
      missingNodesStore.setMissingNodeTypes([
        fromAny<MissingNodeType, unknown>('StringGroupNode')
      ])
      await nextTick()

      expect(groups.missingPackGroups.value).toHaveLength(1)
      expect(groups.missingPackGroups.value[0].packId).toBeNull()
    })
  })

  describe('allErrorGroups', () => {
    it('returns empty array when no errors', () => {
      const { groups } = createErrorGroups()
      expect(groups.allErrorGroups.value).toEqual([])
    })

    it('includes missing_node group when missing nodes exist', async () => {
      const { groups } = createErrorGroups()
      const missingNodesStore = useMissingNodesErrorStore()
      missingNodesStore.setMissingNodeTypes([
        makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
      ])
      await nextTick()

      const missingGroup = groups.allErrorGroups.value.find(
        (g) => g.type === 'missing_node'
      )
      expect(missingGroup).toBeDefined()
      expect(missingGroup?.groupKey).toBe('missing_node')
      expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
      expect(missingGroup?.displayMessage).toBe(
        'Some nodes are missing and need to be installed'
      )
    })

    it('includes swap_nodes group when replaceable nodes exist', async () => {
      const { groups } = createErrorGroups()
      const missingNodesStore = useMissingNodesErrorStore()
      missingNodesStore.setMissingNodeTypes([
        makeMissingNodeType('OldNode', {
          isReplaceable: true,
          replacement: { new_node_id: 'NewNode' }
        })
      ])
      await nextTick()

      const swapGroup = groups.allErrorGroups.value.find(
        (g) => g.type === 'swap_nodes'
      )
      expect(swapGroup).toBeDefined()
    })

    it('includes both swap_nodes and missing_node when both exist', async () => {
      const { groups } = createErrorGroups()
      const missingNodesStore = useMissingNodesErrorStore()
      missingNodesStore.setMissingNodeTypes([
        makeMissingNodeType('OldNode', {
          isReplaceable: true,
          replacement: { new_node_id: 'NewNode' }
        }),
        makeMissingNodeType('MissingNode', {
          nodeId: '2',
          cnrId: 'some-pack'
        })
      ])
      await nextTick()

      const types = groups.allErrorGroups.value.map((g) => g.type)
      expect(types).toContain('swap_nodes')
      expect(types).toContain('missing_node')
    })

    it('swap_nodes has lower priority than missing_node', async () => {
      const { groups } = createErrorGroups()
      const missingNodesStore = useMissingNodesErrorStore()
      missingNodesStore.setMissingNodeTypes([
        makeMissingNodeType('OldNode', {
          isReplaceable: true,
          replacement: { new_node_id: 'NewNode' }
        }),
        makeMissingNodeType('MissingNode', {
          nodeId: '2',
          cnrId: 'some-pack'
        })
      ])
      await nextTick()

      const swapIdx = groups.allErrorGroups.value.findIndex(
        (g) => g.type === 'swap_nodes'
      )
      const missingIdx = groups.allErrorGroups.value.findIndex(
        (g) => g.type === 'missing_node'
      )
      expect(swapIdx).toBeLessThan(missingIdx)
    })

    it('includes execution error groups from node errors', async () => {
      const { store, groups } = createErrorGroups()
      store.lastNodeErrors = {
        '1': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [
            {
              type: 'value_not_valid',
              message: 'Value not valid',
              details: 'some detail'
            }
          ]
        }
      }
      await nextTick()

      const execGroups = groups.allErrorGroups.value.filter(
        (g) => g.type === 'execution'
      )
      expect(execGroups.length).toBeGreaterThan(0)
      expect(execGroups[0].groupKey).toBe('execution:KSampler')
      expect(execGroups[0].displayTitle).toBe('KSampler')
    })

    it('resolves required_input_missing item display copy', async () => {
      const { store, groups } = createErrorGroups()
      store.lastNodeErrors = {
        '1': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [
            {
              type: 'required_input_missing',
              message: 'Required input is missing',
              details: 'model',
              extra_info: {
                input_name: 'model'
              }
            }
          ]
        }
      }
      await nextTick()

      const execGroup = groups.allErrorGroups.value.find(
        (g) => g.type === 'execution'
      )
      expect(execGroup?.type).toBe('execution')
      if (execGroup?.type !== 'execution') return

      const card = execGroup.cards[0]
      const error = card.errors[0]

      expect(error.message).toBe('Required input is missing')
      expect(error.details).toBe('model')
      expect(error.catalogId).toBe('missing_connection')
      expect(error.displayTitle).toBe('Missing connection')
      expect(error.displayMessage).toBe(
        'Required input slots have no connection feeding them.'
      )
      expect(error.displayDetails).toBe(
        'KSampler is missing a required input: model'
      )
      expect(error.displayItemLabel).toBe('KSampler - model')
      expect(error.toastTitle).toBe('Required input missing')
      expect(error.toastMessage).toBe(
        'KSampler is missing a required input: model'
      )
    })

    it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
      mockIsCloud.value = true
      const { store, groups } = createErrorGroups()
      store.lastExecutionError = {
        prompt_id: 'test-prompt',
        timestamp: Date.now(),
        node_id: 5,
        node_type: 'KSampler',
        executed: [],
        exception_type: 'RuntimeError',
        exception_message: 'mat1 and mat2 shapes cannot be multiplied',
        traceback: ['line 1', 'line 2'],
        current_inputs: {},
        current_outputs: {}
      }
      await nextTick()

      const execGroups = groups.allErrorGroups.value.filter(
        (g) => g.type === 'execution'
      )
      expect(execGroups.length).toBeGreaterThan(0)
      if (execGroups[0].type !== 'execution') return
      expect(execGroups[0].cards[0].errors[0]).toMatchObject({
        message: 'RuntimeError: mat1 and mat2 shapes cannot be multiplied',
        details: 'line 1\nline 2',
        isRuntimeError: true,
        exceptionType: 'RuntimeError',
        catalogId: 'execution_failed',
        displayTitle: 'Execution failed',
        displayMessage: 'Node threw an error during execution.',
        displayItemLabel: 'KSampler',
        toastTitle: 'KSampler failed',
        toastMessage:
          'This node threw an error during execution. Check its inputs or try a different configuration.'
      })
    })

    it('adds display fields for targeted runtime execution errors', async () => {
      mockIsCloud.value = true
      const { store, groups } = createErrorGroups()
      store.lastExecutionError = {
        prompt_id: 'test-prompt',
        timestamp: Date.now(),
        node_id: 5,
        node_type: 'KSampler',
        executed: [],
        exception_type: 'torch.OutOfMemoryError',
        exception_message:
          'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.',
        traceback: ['line 1', 'line 2'],
        current_inputs: {},
        current_outputs: {}
      }
      await nextTick()

      const execGroup = groups.allErrorGroups.value.find(
        (g) => g.type === 'execution'
      )
      expect(execGroup?.type).toBe('execution')
      if (execGroup?.type !== 'execution') return

      const error = execGroup.cards[0].errors[0]
      expect(error.message).toContain('torch.OutOfMemoryError:')
      expect(error.catalogId).toBe('out_of_memory')
      expect(error.displayMessage).toBe(
        'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
      )
      expect(error.displayItemLabel).toBe('KSampler')
      expect(error.toastTitle).toBe('Generation failed')
    })

    it('includes prompt error when present', async () => {
      const { store, groups } = createErrorGroups()
      store.lastPromptError = {
        type: 'prompt_no_outputs',
        message: 'No outputs',
        details: ''
      }
      await nextTick()

      const promptGroup = groups.allErrorGroups.value.find(
        (g) =>
          g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
      )
      expect(promptGroup).toBeDefined()
    })

    it('includes prompt error when a node is selected', async () => {
      const { store, groups } = createErrorGroups()
      const canvasStore = useCanvasStore()
      vi.mocked(isLGraphNode).mockReturnValue(true)
      canvasStore.selectedItems = fromAny<
        typeof canvasStore.selectedItems,
        unknown
      >([{ id: '1' }])
      store.lastPromptError = {
        type: 'prompt_no_outputs',
        message: 'No outputs',
        details: ''
      }
      await nextTick()

      const promptGroup = groups.allErrorGroups.value.find(
        (g) =>
          g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
      )
      expect(promptGroup).toBeDefined()
    })

    it('sorts cards within an execution group by nodeId numerically', async () => {
      const { store, groups } = createErrorGroups()
      store.lastNodeErrors = {
        '10': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [{ type: 'err', message: 'Error', details: '' }]
        },
        '2': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [{ type: 'err', message: 'Error', details: '' }]
        },
        '1': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [{ type: 'err', message: 'Error', details: '' }]
        }
      }
      await nextTick()

      const execGroup = groups.allErrorGroups.value.find(
        (g) => g.type === 'execution'
      )
      const nodeIds = execGroup?.cards.map((c) => c.nodeId)
      expect(nodeIds).toEqual(['1', '2', '10'])
    })

    it('sorts cards with subpath nodeIds before higher root IDs', async () => {
      const { store, groups } = createErrorGroups()
      store.lastNodeErrors = {
        '2': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [{ type: 'err', message: 'Error', details: '' }]
        },
        '1:20': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [{ type: 'err', message: 'Error', details: '' }]
        },
        '1': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [{ type: 'err', message: 'Error', details: '' }]
        }
      }
      await nextTick()

      const execGroup = groups.allErrorGroups.value.find(
        (g) => g.type === 'execution'
      )
      const nodeIds = execGroup?.cards.map((c) => c.nodeId)
      expect(nodeIds).toEqual(['1', '1:20', '2'])
    })

    it('sorts deeply nested nodeIds by each segment numerically', async () => {
      const { store, groups } = createErrorGroups()
      store.lastNodeErrors = {
        '10:11:99': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [{ type: 'err', message: 'Error', details: '' }]
        },
        '10:11:12': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [{ type: 'err', message: 'Error', details: '' }]
        },
        '10:2': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [{ type: 'err', message: 'Error', details: '' }]
        }
      }
      await nextTick()

      const execGroup = groups.allErrorGroups.value.find(
        (g) => g.type === 'execution'
      )
      const nodeIds = execGroup?.cards.map((c) => c.nodeId)
      expect(nodeIds).toEqual(['10:2', '10:11:12', '10:11:99'])
    })
  })

  describe('filteredGroups', () => {
    it('returns all groups when search query is empty', async () => {
      const { store, groups } = createErrorGroups()
      store.lastNodeErrors = {
        '1': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
        }
      }
      await nextTick()

      expect(groups.filteredGroups.value.length).toBeGreaterThan(0)
    })

    it('filters groups based on search query', async () => {
      const { store, groups, searchQuery } = createErrorGroups()
      store.lastNodeErrors = {
        '1': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [
            {
              type: 'value_error',
              message: 'Value error in sampler',
              details: ''
            }
          ]
        },
        '2': {
          class_type: 'CLIPLoader',
          dependent_outputs: [],
          errors: [
            {
              type: 'file_not_found',
              message: 'File not found',
              details: ''
            }
          ]
        }
      }
      await nextTick()

      searchQuery.value = 'sampler'
      await nextTick()

      const executionGroups = groups.filteredGroups.value.filter(
        (g) => g.type === 'execution'
      )
      for (const group of executionGroups) {
        if (group.type !== 'execution') continue
        const hasMatch = group.cards.some(
          (c) =>
            c.title.toLowerCase().includes('sampler') ||
            c.errors.some((e) => e.message.toLowerCase().includes('sampler'))
        )
        expect(hasMatch).toBe(true)
      }
    })
  })

  describe('groupedErrorMessages', () => {
    it('returns empty array when no errors', () => {
      const { groups } = createErrorGroups()
      expect(groups.groupedErrorMessages.value).toEqual([])
    })

    it('collects unique error messages from node errors', async () => {
      const { store, groups } = createErrorGroups()
      store.lastNodeErrors = {
        '1': {
          class_type: 'KSampler',
          dependent_outputs: [],
          errors: [
            { type: 'err_a', message: 'Error A', details: '' },
            { type: 'err_b', message: 'Error B', details: '' }
          ]
        },
        '2': {
          class_type: 'CLIPLoader',
          dependent_outputs: [],
          errors: [{ type: 'err_a', message: 'Error A', details: '' }]
        }
      }
      await nextTick()

      const messages = groups.groupedErrorMessages.value
      expect(messages).toContain('Error A')
      expect(messages).toContain('Error B')
      // Deduplication: Error A appears twice but should only be listed once
      expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
    })

    it('includes missing node group display message', async () => {
      const { groups } = createErrorGroups()
      const missingNodesStore = useMissingNodesErrorStore()
      missingNodesStore.setMissingNodeTypes([
        makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
      ])
      await nextTick()

      const missingGroup = groups.allErrorGroups.value.find(
        (g) => g.type === 'missing_node'
      )
      expect(missingGroup).toBeDefined()
      expect(groups.groupedErrorMessages.value).toContain(
        missingGroup!.displayMessage
      )
    })
  })

  describe('missingModelGroups', () => {
    it('returns empty array when no missing models', () => {
      const { groups } = createErrorGroups()
      expect(groups.missingModelGroups.value).toEqual([])
    })

    it('groups asset-supported models by directory', async () => {
      const { store, groups } = createErrorGroups()
      store.surfaceMissingModels([
        makeModel('model_a.safetensors', {
          directory: 'checkpoints',
          isAssetSupported: true
        }),
        makeModel('model_b.safetensors', {
          nodeId: '2',
          directory: 'checkpoints',
          isAssetSupported: true
        }),
        makeModel('lora_a.safetensors', {
          nodeId: '3',
          directory: 'loras',
          isAssetSupported: true
        })
      ])
      await nextTick()

      expect(groups.missingModelGroups.value).toHaveLength(2)
      const ckptGroup = groups.missingModelGroups.value.find(
        (g) => g.directory === 'checkpoints'
      )
      expect(ckptGroup?.models).toHaveLength(2)
      expect(ckptGroup?.isAssetSupported).toBe(true)
    })

    it('puts unsupported models in a separate group', async () => {
      const { store, groups } = createErrorGroups()
      store.surfaceMissingModels([
        makeModel('model_a.safetensors', {
          directory: 'checkpoints',
          isAssetSupported: true
        }),
        makeModel('custom_model.safetensors', {
          nodeId: '2',
          isAssetSupported: false
        })
      ])
      await nextTick()

      expect(groups.missingModelGroups.value).toHaveLength(2)
      const unsupported = groups.missingModelGroups.value.find(
        (g) => !g.isAssetSupported
      )
      expect(unsupported?.models).toHaveLength(1)
    })

    it('merges same-named models into one view model with multiple referencingNodes', async () => {
      const { store, groups } = createErrorGroups()
      store.surfaceMissingModels([
        makeModel('shared_model.safetensors', {
          nodeId: '1',
          widgetName: 'ckpt_name',
          directory: 'checkpoints',
          isAssetSupported: true
        }),
        makeModel('shared_model.safetensors', {
          nodeId: '2',
          widgetName: 'ckpt_name',
          directory: 'checkpoints',
          isAssetSupported: true
        })
      ])
      await nextTick()

      expect(groups.missingModelGroups.value).toHaveLength(1)
      const model = groups.missingModelGroups.value[0].models[0]
      expect(model.name).toBe('shared_model.safetensors')
      expect(model.referencingNodes).toHaveLength(2)
    })

    it('groups non-asset-supported models by directory in OSS', async () => {
      const { store, groups } = createErrorGroups()
      store.surfaceMissingModels([
        makeModel('model_a.safetensors', {
          directory: 'checkpoints',
          isAssetSupported: false
        }),
        makeModel('model_b.safetensors', {
          nodeId: '2',
          directory: 'checkpoints',
          isAssetSupported: false
        }),
        makeModel('lora_a.safetensors', {
          nodeId: '3',
          directory: 'loras',
          isAssetSupported: false
        })
      ])
      await nextTick()

      expect(groups.missingModelGroups.value).toHaveLength(2)
      const ckptGroup = groups.missingModelGroups.value.find(
        (g) => g.directory === 'checkpoints'
      )
      expect(ckptGroup?.models).toHaveLength(2)
      expect(ckptGroup?.isAssetSupported).toBe(false)
      const loraGroup = groups.missingModelGroups.value.find(
        (g) => g.directory === 'loras'
      )
      expect(loraGroup?.models).toHaveLength(1)
    })

    it('does not lump non-asset-supported models into UNSUPPORTED group in OSS', async () => {
      const { store, groups } = createErrorGroups()
      store.surfaceMissingModels([
        makeModel('model_a.safetensors', {
          directory: 'checkpoints',
          isAssetSupported: false
        }),
        makeModel('lora_a.safetensors', {
          nodeId: '2',
          directory: 'loras',
          isAssetSupported: false
        })
      ])
      await nextTick()

      const unsupported = groups.missingModelGroups.value.find(
        (g) => g.directory === null && !g.isAssetSupported
      )
      expect(unsupported).toBeUndefined()
    })

    it('includes missing_model group in allErrorGroups', async () => {
      const { store, groups } = createErrorGroups()
      store.surfaceMissingModels([makeModel('model_a.safetensors')])
      await nextTick()

      const modelGroup = groups.allErrorGroups.value.find(
        (g) => g.type === 'missing_model'
      )
      expect(modelGroup).toBeDefined()
      expect(modelGroup?.groupKey).toBe('missing_model')
      expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
    })
  })

  describe('missingModelGroups (Cloud)', () => {
    beforeEach(() => {
      mockIsCloud.value = true
    })

    afterEach(() => {
      mockIsCloud.value = false
    })

    it('puts unsupported models into UNSUPPORTED group in Cloud', async () => {
      const { store, groups } = createErrorGroups()
      store.surfaceMissingModels([
        makeModel('model_a.safetensors', {
          directory: 'checkpoints',
          isAssetSupported: false
        }),
        makeModel('model_b.safetensors', {
          nodeId: '2',
          directory: 'loras',
          isAssetSupported: false
        })
      ])
      await nextTick()

      expect(groups.missingModelGroups.value).toHaveLength(1)
      expect(groups.missingModelGroups.value[0].isAssetSupported).toBe(false)
      expect(groups.missingModelGroups.value[0].directory).toBeNull()
    })

    it('groups asset-supported models by directory in Cloud', async () => {
      const { store, groups } = createErrorGroups()
      store.surfaceMissingModels([
        makeModel('model_a.safetensors', {
          directory: 'checkpoints',
          isAssetSupported: true
        }),
        makeModel('model_b.safetensors', {
          nodeId: '2',
          directory: 'loras',
          isAssetSupported: true
        })
      ])
      await nextTick()

      expect(groups.missingModelGroups.value).toHaveLength(2)
      expect(
        groups.missingModelGroups.value.every((g) => g.isAssetSupported)
      ).toBe(true)
    })
  })

  describe('unfiltered vs selection-filtered model/media groups', () => {
    it('exposes both unfiltered (missingModelGroups) and filtered (filteredMissingModelGroups)', () => {
      const { groups } = createErrorGroups()
      expect(groups.missingModelGroups).toBeDefined()
      expect(groups.filteredMissingModelGroups).toBeDefined()
      expect(groups.missingMediaGroups).toBeDefined()
      expect(groups.filteredMissingMediaGroups).toBeDefined()
    })

    it('missingModelGroups returns total candidates regardless of selection (ErrorOverlay contract)', async () => {
      const { store, groups } = createErrorGroups()
      store.surfaceMissingModels([
        makeModel('a.safetensors', { nodeId: '1', directory: 'checkpoints' }),
        makeModel('b.safetensors', { nodeId: '2', directory: 'checkpoints' })
      ])
      // Simulate canvas selection of a single node so the filtered
      // variant actually narrows. Without this, both sides return the
      // same value trivially and the test can't prove the contract.
      vi.mocked(isLGraphNode).mockReturnValue(true)
      const canvasStore = useCanvasStore()
      canvasStore.selectedItems = fromAny<
        typeof canvasStore.selectedItems,
        unknown
      >([{ id: '1' }])
      await nextTick()

      // Unfiltered total stays at one group of two models regardless of
      // the selection — ErrorOverlay reads this for the overlay label
      // and must not shrink with canvas selection.
      expect(groups.missingModelGroups.value).toHaveLength(1)
      expect(groups.missingModelGroups.value[0].models).toHaveLength(2)

      // Filtered variant does narrow under the same selection state —
      // this is how the errors tab scopes cards to the selected node.
      // Exact filtered output depends on the app.rootGraph lookup
      // (mocked to return undefined here); what matters is that the
      // filtered shape is a different reference and does not blindly
      // mirror the unfiltered one.
      expect(groups.filteredMissingModelGroups.value).not.toBe(
        groups.missingModelGroups.value
      )
    })
  })

  describe('tabErrorGroups', () => {
    it('filters prompt error when a node is selected', async () => {
      const { store, groups } = createErrorGroups()
      const canvasStore = useCanvasStore()
      vi.mocked(isLGraphNode).mockReturnValue(true)
      canvasStore.selectedItems = fromAny<
        typeof canvasStore.selectedItems,
        unknown
      >([{ id: '1' }])
      store.lastPromptError = {
        type: 'prompt_no_outputs',
        message: 'No outputs',
        details: ''
      }
      await nextTick()

      const promptGroup = groups.tabErrorGroups.value.find(
        (g) =>
          g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
      )
      expect(promptGroup).toBeUndefined()
    })
  })
})
