import { describe, expect, it, vi } from 'vitest'
import { SVG_NS } from './svg.js'
import {
  createVNode,
  EXISTING_NODE,
  flattenVChildren,
  FRAGMENT,
  isVNode,
  isVTextNode,
  mountChild,
  patchChildren,
  patchProps,
  shallowEqual,
  toVChildArray,
  unmountChild,
  type VChild,
  type VNode,
  type VTextNode,
} from './vnode.js'

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const vtext = (text: string): VTextNode => ({ _brand: 'vtext', text })

const vel = (tag: string, props: Record<string, unknown> | null, ...children: VChild[]): VNode => ({
  _brand: 'vnode',
  type: tag,
  props: props ?? {},
  children,
})

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('vnode', () => {
  describe('createVNode', () => {
    it('should create an intrinsic element VNode', () => {
      const vnode = createVNode('div', { id: 'test' }, 'hello')
      expect(vnode._brand).toBe('vnode')
      expect(vnode.type).toBe('div')
      expect(vnode.props).toEqual({ id: 'test' })
      expect(vnode.children).toHaveLength(1)
      expect(vnode.children[0]).toEqual({ _brand: 'vtext', text: 'hello' })
    })

    it('should create a fragment VNode', () => {
      const child = createVNode('p', null, 'text')
      const vnode = createVNode(null, null, child)
      expect(vnode.type).toBe(FRAGMENT)
      expect(vnode.children).toEqual([child])
    })

    it('should flatten nested arrays', () => {
      const vnode = createVNode('ul', null, [createVNode('li', null, 'a'), createVNode('li', null, 'b')])
      expect(vnode.children).toHaveLength(2)
    })

    it('should skip null and boolean children', () => {
      const vnode = createVNode('div', null, null, false, true, undefined, 'kept')
      expect(vnode.children).toHaveLength(1)
      expect((vnode.children[0] as VTextNode).text).toBe('kept')
    })

    it('should inline fragment children', () => {
      const fragment = createVNode(null, null, 'a', 'b')
      const vnode = createVNode('div', null, fragment)
      expect(vnode.children).toHaveLength(2)
      expect((vnode.children[0] as VTextNode).text).toBe('a')
      expect((vnode.children[1] as VTextNode).text).toBe('b')
    })

    it('should convert numbers to text nodes', () => {
      const vnode = createVNode('span', null, 42)
      expect(vnode.children).toHaveLength(1)
      expect((vnode.children[0] as VTextNode).text).toBe('42')
    })

    it('should normalize null props to empty object', () => {
      const vnode = createVNode('div', null)
      expect(vnode.props).toEqual({})
      expect(vnode.props).not.toBeNull()
    })
  })

  describe('flattenVChildren', () => {
    it('should wrap real DOM nodes as EXISTING_NODE VNodes', () => {
      const div = document.createElement('div')
      const result = flattenVChildren([div])
      expect(result).toHaveLength(1)
      expect(isVNode(result[0])).toBe(true)
      expect((result[0] as VNode).type).toBe(EXISTING_NODE)
      expect((result[0] as VNode)._el).toBe(div)
    })
  })

  describe('type guards', () => {
    it('isVNode should identify VNodes', () => {
      expect(isVNode(createVNode('div', null))).toBe(true)
      expect(isVNode({ _brand: 'vtext', text: 'hello' })).toBe(false)
      expect(isVNode(null)).toBe(false)
      expect(isVNode('string')).toBe(false)
    })

    it('isVTextNode should identify VTextNodes', () => {
      expect(isVTextNode({ _brand: 'vtext', text: 'hi' })).toBe(true)
      expect(isVTextNode(createVNode('div', null))).toBe(false)
    })
  })

  describe('shallowEqual', () => {
    it('should return true for identical references', () => {
      const obj = { a: 1 }
      expect(shallowEqual(obj, obj)).toBe(true)
    })

    it('should return true for equal props', () => {
      expect(shallowEqual({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toBe(true)
    })

    it('should return false for different values', () => {
      expect(shallowEqual({ a: 1 }, { a: 2 })).toBe(false)
    })

    it('should return false for different key counts', () => {
      expect(shallowEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false)
    })

    it('should return true for two empty objects', () => {
      expect(shallowEqual({}, {})).toBe(true)
    })
  })

  describe('toVChildArray', () => {
    it('should return empty array for null', () => {
      expect(toVChildArray(null)).toEqual([])
    })

    it('should wrap string as VTextNode', () => {
      const result = toVChildArray('hello')
      expect(result).toHaveLength(1)
      expect(isVTextNode(result[0])).toBe(true)
    })

    it('should unwrap fragment VNode children', () => {
      const fragment = createVNode(null, null, createVNode('p', null, 'a'), createVNode('p', null, 'b'))
      const result = toVChildArray(fragment)
      expect(result).toHaveLength(2)
    })

    it('should wrap single VNode in array', () => {
      const vnode = createVNode('div', null, 'text')
      const result = toVChildArray(vnode)
      expect(result).toEqual([vnode])
    })

    it('should wrap real DOM element as EXISTING_NODE', () => {
      const el = document.createElement('div')
      const result = toVChildArray(el)
      expect(result).toHaveLength(1)
      expect((result[0] as VNode).type).toBe(EXISTING_NODE)
      expect((result[0] as VNode)._el).toBe(el)
    })

    it('should wrap DocumentFragment children as EXISTING_NODE VNodes', () => {
      const fragment = document.createDocumentFragment()
      fragment.appendChild(document.createElement('span'))
      fragment.appendChild(document.createTextNode('text'))
      const result = toVChildArray(fragment)
      expect(result).toHaveLength(2)
      expect((result[0] as VNode).type).toBe(EXISTING_NODE)
      expect((result[0] as VNode)._el).toBeInstanceOf(HTMLSpanElement)
      expect((result[1] as VNode).type).toBe(EXISTING_NODE)
      expect((result[1] as VNode)._el).toBeInstanceOf(Text)
    })

    it('should convert number to VTextNode', () => {
      const result = toVChildArray(42)
      expect(result).toHaveLength(1)
      expect(isVTextNode(result[0])).toBe(true)
      expect((result[0] as VTextNode).text).toBe('42')
    })

    it('should return empty array for undefined', () => {
      expect(toVChildArray(undefined)).toEqual([])
    })
  })

  describe('mountChild', () => {
    it('should mount a text node', () => {
      const parent = document.createElement('div')
      mountChild(vtext('hello'), parent)
      expect(parent.textContent).toBe('hello')
    })

    it('should mount an intrinsic element with props', () => {
      const parent = document.createElement('div')
      const child = vel('span', { id: 'test', className: 'cls' }, vtext('content'))
      mountChild(child, parent)
      const span = parent.querySelector('span')!
      expect(span.id).toBe('test')
      expect(span.className).toBe('cls')
      expect(span.textContent).toBe('content')
      expect(child._el).toBe(span)
    })

    it('should mount nested children', () => {
      const parent = document.createElement('div')
      const child = vel('ul', null, vel('li', null, vtext('a')), vel('li', null, vtext('b')))
      mountChild(child, parent)
      expect(parent.innerHTML).toBe('<ul><li>a</li><li>b</li></ul>')
    })

    it('should mount an EXISTING_NODE by appending the real element', () => {
      const parent = document.createElement('div')
      const existing = document.createElement('span')
      existing.textContent = 'existing'
      const child: VNode = { _brand: 'vnode', type: EXISTING_NODE, props: {}, children: [], _el: existing }
      mountChild(child, parent)
      expect(parent.firstChild).toBe(existing)
    })

    it('should set _el on text nodes', () => {
      const parent = document.createElement('div')
      const child = vtext('hi')
      mountChild(child, parent)
      expect(child._el).toBeInstanceOf(Text)
      expect(child._el?.textContent).toBe('hi')
    })

    it('should create SVG elements with createElementNS', () => {
      const parent = document.createElement('div')
      const child = vel('svg', { viewBox: '0 0 100 100' }, vel('circle', { cx: '50', cy: '50', r: '40' }))
      mountChild(child, parent)
      const svg = parent.querySelector('svg')
      expect(svg).toBeInstanceOf(SVGElement)
      expect(svg?.namespaceURI).toBe(SVG_NS)
      expect(svg?.getAttribute('viewBox')).toBe('0 0 100 100')
      const circle = svg?.querySelector('circle')
      expect(circle).toBeInstanceOf(SVGElement)
      expect(circle?.namespaceURI).toBe(SVG_NS)
      expect(circle?.getAttribute('cx')).toBe('50')
    })

    it('should set className as class attribute on SVG elements', () => {
      const parent = document.createElement('div')
      const child = vel('g', { className: 'my-group' })
      mountChild(child, parent)
      const g = parent.querySelector('g')
      expect(g?.getAttribute('class')).toBe('my-group')
    })

    it('should attach event handlers as properties on SVG elements', () => {
      const parent = document.createElement('div')
      const handler = vi.fn()
      const child = vel('rect', { onclick: handler })
      mountChild(child, parent)
      const rect = parent.querySelector('rect')
      expect((rect as unknown as Record<string, unknown>).onclick).toBe(handler)
    })

    it('should handle SVG elements with style props', () => {
      const parent = document.createElement('div')
      const child = vel('rect', { style: { fill: 'red', strokeWidth: '2px' } })
      mountChild(child, parent)
      const rect = parent.querySelector('rect') as SVGElement
      expect(rect.style.fill).toBe('red')
      expect(rect.style.strokeWidth).toBe('2px')
    })

    it('should not mount EXISTING_NODE when _el is undefined', () => {
      const parent = document.createElement('div')
      const child: VNode = { _brand: 'vnode', type: EXISTING_NODE, props: {}, children: [] }
      const result = mountChild(child, parent)
      expect(result).toBeUndefined()
      expect(parent.childNodes.length).toBe(0)
    })

    it('should set ref on mounted intrinsic elements', () => {
      const parent = document.createElement('div')
      const ref = { current: null } as { current: Element | null }
      const child = vel('input', { ref })
      mountChild(child, parent)
      expect(ref.current).toBeInstanceOf(HTMLInputElement)
      expect(ref.current).toBe(parent.querySelector('input'))
    })
  })

  describe('unmountChild', () => {
    it('should remove a mounted element from the DOM', () => {
      const parent = document.createElement('div')
      const child = vel('span', null, vtext('bye'))
      mountChild(child, parent)
      expect(parent.children.length).toBe(1)
      unmountChild(child)
      expect(parent.children.length).toBe(0)
    })

    it('should remove a mounted text node from the DOM', () => {
      const parent = document.createElement('div')
      const child = vtext('bye')
      mountChild(child, parent)
      expect(parent.childNodes.length).toBe(1)
      unmountChild(child)
      expect(parent.childNodes.length).toBe(0)
    })
  })

  describe('patchProps', () => {
    it('should add new props', () => {
      const el = document.createElement('div')
      patchProps(el, {}, { id: 'new' })
      expect(el.id).toBe('new')
    })

    it('should update changed props', () => {
      const el = document.createElement('div')
      el.id = 'old'
      patchProps(el, { id: 'old' }, { id: 'new' })
      expect(el.id).toBe('new')
    })

    it('should remove stale event handlers', () => {
      const el = document.createElement('div')
      const handler = vi.fn()
      el.onclick = handler
      patchProps(el, { onclick: handler }, {})
      expect(el.onclick).toBeNull()
    })

    it('should update event handlers', () => {
      const el = document.createElement('button')
      const handler1 = vi.fn()
      const handler2 = vi.fn()
      patchProps(el, { onclick: handler1 }, { onclick: handler2 })
      expect(el.onclick).toBe(handler2)
    })

    it('should patch styles', () => {
      const el = document.createElement('div')
      patchProps(el, { style: { color: 'red', fontSize: '14px' } }, { style: { color: 'blue' } })
      expect(el.style.color).toBe('blue')
      expect(el.style.fontSize).toBe('')
    })

    it('should set data attributes', () => {
      const el = document.createElement('div')
      patchProps(el, {}, { 'data-testid': 'foo' })
      expect(el.getAttribute('data-testid')).toBe('foo')
    })

    it('should remove data attributes', () => {
      const el = document.createElement('div')
      el.setAttribute('data-testid', 'foo')
      patchProps(el, { 'data-testid': 'foo' }, {})
      expect(el.hasAttribute('data-testid')).toBe(false)
    })

    describe('SVG elements', () => {
      it('should set attributes via setAttribute on SVG elements', () => {
        const el = document.createElementNS(SVG_NS, 'rect')
        patchProps(el, {}, { width: '100', height: '50', rx: '5' })
        expect(el.getAttribute('width')).toBe('100')
        expect(el.getAttribute('height')).toBe('50')
        expect(el.getAttribute('rx')).toBe('5')
      })

      it('should set className as class attribute on SVG elements', () => {
        const el = document.createElementNS(SVG_NS, 'g')
        patchProps(el, {}, { className: 'my-group' })
        expect(el.getAttribute('class')).toBe('my-group')
      })

      it('should remove attributes from SVG elements', () => {
        const el = document.createElementNS(SVG_NS, 'circle')
        el.setAttribute('fill', 'red')
        patchProps(el, { fill: 'red' }, {})
        expect(el.hasAttribute('fill')).toBe(false)
      })

      it('should remove className as class from SVG elements', () => {
        const el = document.createElementNS(SVG_NS, 'g')
        el.setAttribute('class', 'old')
        patchProps(el, { className: 'old' }, {})
        expect(el.hasAttribute('class')).toBe(false)
      })

      it('should remove attributes when value is null/undefined/false on SVG elements', () => {
        const el = document.createElementNS(SVG_NS, 'rect')
        el.setAttribute('fill', 'red')
        patchProps(el, { fill: 'red' }, { fill: null })
        expect(el.hasAttribute('fill')).toBe(false)
      })

      it('should set event handlers as properties on SVG elements', () => {
        const el = document.createElementNS(SVG_NS, 'rect')
        const handler = vi.fn()
        patchProps(el, {}, { onclick: handler })
        expect((el as unknown as Record<string, unknown>).onclick).toBe(handler)
      })
    })
  })

  describe('patchChildren', () => {
    it('should mount all children when old is empty', () => {
      const parent = document.createElement('div')
      const newChildren: VChild[] = [vel('span', null, vtext('a')), vel('span', null, vtext('b'))]
      patchChildren(parent, [], newChildren)
      expect(parent.children.length).toBe(2)
      expect(parent.children[0].textContent).toBe('a')
      expect(parent.children[1].textContent).toBe('b')
    })

    it('should remove all children when new is empty', () => {
      const parent = document.createElement('div')
      const oldChildren: VChild[] = [vel('span', null, vtext('a'))]
      patchChildren(parent, [], oldChildren) // mount first
      patchChildren(parent, oldChildren, [])
      expect(parent.children.length).toBe(0)
    })

    it('should patch matching text nodes', () => {
      const parent = document.createElement('div')
      const old: VChild[] = [vtext('old')]
      patchChildren(parent, [], old)
      const textNode = parent.firstChild!
      const updated: VChild[] = [vtext('new')]
      patchChildren(parent, old, updated)
      expect(parent.firstChild).toBe(textNode)
      expect(parent.textContent).toBe('new')
    })

    it('should patch matching intrinsic elements in place', () => {
      const parent = document.createElement('div')
      const old: VChild[] = [vel('span', { id: 'a' }, vtext('old'))]
      patchChildren(parent, [], old)
      const span = parent.querySelector('span')!

      const updated: VChild[] = [vel('span', { id: 'b' }, vtext('new'))]
      patchChildren(parent, old, updated)

      expect(parent.querySelector('span')).toBe(span)
      expect(span.id).toBe('b')
      expect(span.textContent).toBe('new')
    })

    it('should replace when types differ', () => {
      const parent = document.createElement('div')
      const old: VChild[] = [vel('div', null, vtext('div'))]
      patchChildren(parent, [], old)

      const updated: VChild[] = [vel('span', null, vtext('span'))]
      patchChildren(parent, old, updated)

      expect(parent.children[0].tagName).toBe('SPAN')
      expect(parent.textContent).toBe('span')
    })

    it('should add excess new children', () => {
      const parent = document.createElement('div')
      const old: VChild[] = [vel('p', null, vtext('a'))]
      patchChildren(parent, [], old)

      const updated: VChild[] = [vel('p', null, vtext('a')), vel('p', null, vtext('b'))]
      patchChildren(parent, old, updated)

      expect(parent.children.length).toBe(2)
    })

    it('should remove excess old children', () => {
      const parent = document.createElement('div')
      const old: VChild[] = [vel('p', null, vtext('a')), vel('p', null, vtext('b'))]
      patchChildren(parent, [], old)

      const updated: VChild[] = [vel('p', null, vtext('only'))]
      patchChildren(parent, old, updated)

      expect(parent.children.length).toBe(1)
      expect(parent.textContent).toBe('only')
    })

    it('should preserve element identity across patches', () => {
      const parent = document.createElement('div')
      const old: VChild[] = [vel('input', { type: 'text' })]
      patchChildren(parent, [], old)
      const input = parent.querySelector('input')!

      const updated: VChild[] = [vel('input', { type: 'text', id: 'updated' })]
      patchChildren(parent, old, updated)

      expect(parent.querySelector('input')).toBe(input)
      expect(input.id).toBe('updated')
    })

    it('should handle EXISTING_NODE patching (same reference)', () => {
      const parent = document.createElement('div')
      const real = document.createElement('span')
      real.textContent = 'real'

      const old: VChild[] = [{ _brand: 'vnode', type: EXISTING_NODE, props: {}, children: [], _el: real }]
      patchChildren(parent, [], old)
      expect(parent.firstChild).toBe(real)

      const updated: VChild[] = [{ _brand: 'vnode', type: EXISTING_NODE, props: {}, children: [], _el: real }]
      patchChildren(parent, old, updated)
      expect(parent.firstChild).toBe(real)
    })

    it('should mount new child into parent when types differ and old node is detached', () => {
      const parent = document.createElement('div')
      const old: VChild[] = [vel('div', null, vtext('div'))]
      patchChildren(parent, [], old)

      // Detach old node manually to simulate a detached state
      const oldNode = old[0]._el!
      oldNode.parentNode!.removeChild(oldNode)

      const updated: VChild[] = [vel('span', null, vtext('span'))]
      patchChildren(parent, old, updated)

      expect(parent.children.length).toBe(1)
      expect(parent.children[0].tagName).toBe('SPAN')
    })

    it('should clear ref on unmount', () => {
      const parent = document.createElement('div')
      const ref = { current: null } as { current: Element | null }
      const child = vel('input', { ref })
      patchChildren(parent, [], [child])
      expect(ref.current).toBeInstanceOf(HTMLInputElement)

      patchChildren(parent, [child], [])
      expect(ref.current).toBeNull()
    })

    describe('Shade component boundaries', () => {
      it('should call updateComponentSync on child Shade when props change', () => {
        const parent = document.createElement('div')

        const fakeShadeEl = document.createElement('my-shade') as unknown as JSX.Element
        const updateFn = vi.fn()
        ;(fakeShadeEl as unknown as Record<string, unknown>).updateComponentSync = updateFn
        ;(fakeShadeEl as unknown as Record<string, unknown>).props = { count: 1 }
        ;(fakeShadeEl as unknown as Record<string, unknown>).shadeChildren = undefined

        const factory = vi.fn(() => fakeShadeEl)

        const old: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [], _el: fakeShadeEl }]
        parent.appendChild(fakeShadeEl)

        const updated: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 2 }, children: [] }]
        patchChildren(parent, old, updated)

        expect(updateFn).toHaveBeenCalledOnce()
        expect(fakeShadeEl.props).toEqual({ count: 2 })
      })

      it('should set empty object (not null) on Shade when props transition to none', () => {
        const parent = document.createElement('div')

        const fakeShadeEl = document.createElement('my-shade-3') as unknown as JSX.Element
        const updateFn = vi.fn()
        ;(fakeShadeEl as unknown as Record<string, unknown>).updateComponentSync = updateFn
        ;(fakeShadeEl as unknown as Record<string, unknown>).props = { elevation: 2 }
        ;(fakeShadeEl as unknown as Record<string, unknown>).shadeChildren = undefined

        const factory = vi.fn(() => fakeShadeEl)

        const old: VChild[] = [
          { _brand: 'vnode', type: factory, props: { elevation: 2 }, children: [], _el: fakeShadeEl },
        ]
        parent.appendChild(fakeShadeEl)

        const updated: VChild[] = [{ _brand: 'vnode', type: factory, props: {}, children: [] }]
        patchChildren(parent, old, updated)

        expect(updateFn).toHaveBeenCalledOnce()
        expect(fakeShadeEl.props).toEqual({})
        expect(fakeShadeEl.props).not.toBeNull()
      })

      it('should NOT call updateComponentSync when props are unchanged', () => {
        const parent = document.createElement('div')

        const fakeShadeEl = document.createElement('my-shade-2') as unknown as JSX.Element
        const updateFn = vi.fn()
        const props = { count: 1 }
        ;(fakeShadeEl as unknown as Record<string, unknown>).updateComponentSync = updateFn
        ;(fakeShadeEl as unknown as Record<string, unknown>).props = props
        ;(fakeShadeEl as unknown as Record<string, unknown>).shadeChildren = undefined

        const factory = vi.fn(() => fakeShadeEl)

        const old: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [], _el: fakeShadeEl }]
        parent.appendChild(fakeShadeEl)

        const updated: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [] }]
        patchChildren(parent, old, updated)

        expect(updateFn).not.toHaveBeenCalled()
      })
    })
  })
})
