// __tests__/modules.pseudo.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { inlinePseudoElements } from '../src/modules/pseudo.js'

// Mock de utils y fonts con importActual para que Vitest Browser no rompa
vi.mock('../src/utils', async (importOriginal) => {
  const actual = await importOriginal()
  return {
    ...actual,
    fetchImage: vi.fn(),
    inlineSingleBackgroundEntry: vi.fn(), // agregado para cubrir casos extra
  }
})

vi.mock('../src/modules/fonts.js', async (importOriginal) => {
  const actual = await importOriginal()
  return {
    ...actual,
    iconToImage: vi.fn(),
  }
})

vi.mock('../src/modules/counter.js', async (importOriginal) => {
  const actual = await importOriginal()
  return {
    ...actual,
    buildCounterContext: vi.fn(actual.buildCounterContext),
  }
})

import * as helpers from '../src/utils/index.js'
import * as fonts from '../src/modules/fonts.js'
import { buildCounterContext } from '../src/modules/counter.js'

const sessionCache = {
  styleMap: new Map(),
  styleCache: new WeakMap()
}

describe('inlinePseudoElements', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('does not fail with simple elements', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    await expect(inlinePseudoElements(el, clone, sessionCache, {})).resolves.toBeUndefined()
  })

  it('handles ::before with text content', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) =>
          prop === 'content' ? '"★"'
            : prop === 'font-family' ? 'Arial'
            : prop === 'font-size' ? '32'
            : prop === 'font-weight' ? '400'
            : prop === 'color' ? '#000'
            : prop === 'background-image' ? 'none'
            : prop === 'background-color' ? 'transparent'
            : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
      }
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }
    })
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('handles ::before with icon font', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) =>
          prop === 'content' ? '"★"'
            : prop === 'font-family' ? 'Font Awesome'
            : prop === 'font-size' ? '32'
            : prop === 'font-weight' ? '400'
            : prop === 'color' ? '#000'
            : prop === 'background-image' ? 'none'
            : prop === 'background-color' ? 'transparent'
            : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Font Awesome'
      }
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }
    })
    fonts.iconToImage.mockResolvedValue('data:image/png;base64,icon')
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('passes the pseudo-element font style to icon rasterization', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    const styleTag = document.createElement('style')
    styleTag.textContent = `.pseudo-font-style-target::before {
      content: "\\e001";
      font-family: "Font Awesome";
      font-size: 32px;
      font-weight: 700;
      font-style: italic;
      color: rgb(18, 52, 86);
    }`
    document.head.appendChild(styleTag)
    el.className = 'pseudo-font-style-target'
    document.body.appendChild(el)
    fonts.iconToImage.mockResolvedValue({ dataUrl: 'data:image/png;base64,icon', width: 32, height: 32 })
    const pseudoStyle = getComputedStyle(el, '::before')

    try {
      await inlinePseudoElements(el, clone, sessionCache, {})
      expect(fonts.iconToImage).toHaveBeenCalledWith(
        '\ue001', pseudoStyle.fontFamily, 700, 32, pseudoStyle.color, 'italic'
      )
    } finally {
      el.remove()
      styleTag.remove()
    }
  })

  it('handles ::before with url content', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) =>
          prop === 'content' ? 'url("https://test.com/img.png")'
            : prop === 'font-family' ? 'Arial'
            : prop === 'font-size' ? '32'
            : prop === 'font-weight' ? '400'
            : prop === 'color' ? '#000'
            : prop === 'background-image' ? 'none'
            : prop === 'background-color' ? 'transparent'
            : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
      }
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }
    })
    helpers.fetchImage.mockResolvedValue('data:image/png;base64,img')
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('handles ::before with background-image (data url)', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) =>
          prop === 'content' ? 'none'
            : prop === 'font-family' ? 'Arial'
            : prop === 'font-size' ? '32'
            : prop === 'font-weight' ? '400'
            : prop === 'color' ? '#000'
            : prop === 'background-image' ? 'url("data:image/png;base64,abc")'
            : prop === 'background-color' ? 'transparent'
            : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
      }
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }
    })
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('handles ::before with background-image (fetch ok)', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) =>
          prop === 'content' ? 'none'
            : prop === 'font-family' ? 'Arial'
            : prop === 'font-size' ? '32'
            : prop === 'font-weight' ? '400'
            : prop === 'color' ? '#000'
            : prop === 'background-image' ? 'url("https://test.com/img.png")'
            : prop === 'background-color' ? 'transparent'
            : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
      }
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }
    })
    helpers.fetchImage.mockResolvedValue('data:image/png;base64,img')
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('handles ::before with background-image (fetch error)', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) =>
          prop === 'content' ? 'none'
            : prop === 'font-family' ? 'Arial'
            : prop === 'font-size' ? '32'
            : prop === 'font-weight' ? '400'
            : prop === 'color' ? '#000'
            : prop === 'background-image' ? 'url("https://test.com/img.png")'
            : prop === 'background-color' ? 'transparent'
            : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
      }
      return { getPropertyValue: () => '', color: '', fontSize: '', fontWeight: '', fontFamily: '' }
    })
    helpers.fetchImage.mockRejectedValue(new Error('fail'))
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('cubre el catch de error en inlineSingleBackgroundEntry', async () => {
    helpers.inlineSingleBackgroundEntry.mockRejectedValue(new Error('fail'))
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => prop === 'background-image' ? 'url("data:image/png;base64,abc")' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
      }
      return { getPropertyValue: () => '' }
    })
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('cubre el inlining exitoso con inlineSingleBackgroundEntry', async () => {
    helpers.inlineSingleBackgroundEntry.mockResolvedValue('url("data:image/png;base64,abc")')
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => prop === 'background-image' ? 'url("https://test.com/img.png")' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
      }
      return { getPropertyValue: () => '' }
    })
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('handles ::before with no visible box', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return { getPropertyValue: () => 'none' }
      return { getPropertyValue: () => '' }
    })
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('does not add suppression markers to hosts without pseudo rules', async () => {
    const style = document.createElement('style')
    style.textContent = '.pseudo-sentinel::before { content: "x"; }'
    document.head.appendChild(style)
    const source = document.createElement('div')
    const clone = source.cloneNode(true)

    try {
      await inlinePseudoElements(source, clone, { styleMap: new Map(), styleCache: new WeakMap() }, {})

      expect(clone.hasAttribute('data-snapdom-has-before')).toBe(false)
      expect(clone.hasAttribute('data-snapdom-has-after')).toBe(false)
    } finally {
      style.remove()
    }
  })

  it('does not materialize and suppresses non-generated before and after pseudo-elements', async () => {
    const cases = [
      ['content-none', '::before'],
      ['content-normal', '::before'],
      ['display-none', '::after'],
    ]
    const host = document.createElement('div')
    const style = document.createElement('style')
    style.textContent = `
      .content-none::before {
        content: none;
        display: block;
        width: 20px;
        height: 20px;
        background-color: red;
      }
      .content-normal::before {
        content: normal;
        display: block;
        width: 20px;
        height: 20px;
        background-color: red;
      }
      .display-none::after {
        content: "visible text";
        display: none;
      }
    `
    document.head.appendChild(style)
    document.body.appendChild(host)

    try {
      const materialized = {}
      const suppressed = {}
      for (const [className, pseudo] of cases) {
        const source = document.createElement('div')
        source.className = className
        host.appendChild(source)
        const clone = source.cloneNode(true)
        const localSessionCache = { styleMap: new Map(), styleCache: new WeakMap() }

        await inlinePseudoElements(source, clone, localSessionCache, {})

        materialized[className] = Boolean(clone.querySelector(`[data-snapdom-pseudo="${pseudo}"]`))
        const marker = pseudo === '::before'
          ? 'data-snapdom-has-before'
          : 'data-snapdom-has-after'
        suppressed[className] = clone.hasAttribute(marker)
      }
      expect(materialized).toEqual({
        'content-none': false,
        'content-normal': false,
        'display-none': false,
      })
      expect(suppressed).toEqual({
        'content-none': true,
        'content-normal': true,
        'display-none': true,
      })
    } finally {
      host.remove()
      style.remove()
    }
  })

  it('handles ::first-letter with no textNode', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({ getPropertyValue: () => '' }))
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('handles error in pseudo processing', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation(() => { throw new Error('fail') })
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('ignores if source no es Element', async () => {
    const notElement = {}
    const clone = document.createElement('div')
    await expect(inlinePseudoElements(notElement, clone, sessionCache, {})).resolves.toBeUndefined()
  })

  it('ignores if clone no es Element', async () => {
    const el = document.createElement('div')
    const notElement = {}
    await expect(inlinePseudoElements(el, notElement, {})).resolves.toBeUndefined()
  })

  it('inserta pseudoEl como ::after', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::after') return {
        getPropertyValue: (prop) => prop === 'content' ? '"after"' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
      }
      return { getPropertyValue: () => '' }
    })
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('inserta pseudoEl como ::before', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return {
        getPropertyValue: (prop) => prop === 'content' ? '"before"' : '',
        color: '#000', fontSize: '32px', fontWeight: '400', fontFamily: 'Arial'
      }
      return { getPropertyValue: () => '' }
    })
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('maneja ::first-letter meaningful', async () => {
    const el = document.createElement('div')
    el.textContent = 'Test'
    const clone = document.createElement('div')
    clone.textContent = 'Test'
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::first-letter') return {
        getPropertyValue: (prop) => prop === 'color' ? '#f00' : '', color: '#f00', fontSize: '32px'
      }
      return { getPropertyValue: () => '' }
    })
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('inserta ambos pseudoEl ::before y ::after', async () => {
    const el = document.createElement('div')
    const clone = document.createElement('div')
    vi.spyOn(window, 'getComputedStyle').mockImplementation((_, pseudo) => {
      if (pseudo === '::before') return { getPropertyValue: () => '"before"' }
      if (pseudo === '::after') return { getPropertyValue: () => '"after"' }
      return { getPropertyValue: () => '' }
    })
    await inlinePseudoElements(el, clone, sessionCache, {})
    window.getComputedStyle.mockRestore()
  })

  it('should inline ::first-letter when style is meaningful', async () => {
    const el = document.createElement('p')
    el.textContent = '¡Hola mundo!'
    el.style.setProperty('color', 'black')
    document.body.appendChild(el)
    const clone = el.cloneNode(true)
    const style = document.createElement('style')
    style.textContent = `
      p::first-letter {
        color: red;
        font-size: 200%;
      }
    `
    document.head.appendChild(style)
    await inlinePseudoElements(el, clone, sessionCache, {})
    const firstLetterEl = clone.querySelector('[data-snapdom-pseudo="::first-letter"]')
    expect(firstLetterEl).toBeTruthy()
    expect(firstLetterEl.textContent.length).toBeGreaterThan(0)
  })

  it('#447: does not wrap the first letter of a textarea (would drop it from the value)', async () => {
    const ta = document.createElement('textarea')
    ta.value = 'ajdalfjllalkj'
    document.body.appendChild(ta)
    const style = document.createElement('style')
    style.textContent = 'li::before { content: "x"; }'
    document.head.appendChild(style)
    const clone = ta.cloneNode(true)
    clone.textContent = ta.value
    await inlinePseudoElements(ta, clone, sessionCache, {})
    expect(clone.querySelector('[data-snapdom-pseudo]')).toBeNull()
    expect(clone.value).toBe('ajdalfjllalkj')
  })

  it('should inline background-image entries for pseudo-element', async () => {
    const el = document.createElement('div')
    document.body.appendChild(el)
    const style = document.createElement('style')
    style.textContent = `
      div::after {
        content: " ";
        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='10' height='10' fill='blue'/%3E%3C/svg%3E");
        display: inline-block;
        width: 10px;
        height: 10px;
      }
    `
    document.head.appendChild(style)
    const clone = el.cloneNode(true)
    await inlinePseudoElements(el, clone, sessionCache, {})
    const pseudoAfter = clone.querySelector('[data-snapdom-pseudo="::after"]')
    expect(pseudoAfter).toBeTruthy()
    expect(pseudoAfter.style.backgroundImage.startsWith('url("data:image/')).toBeTruthy()
  })

  // Regression: issue #235 — `content: counter(x) ")"` rendered as `1 )` (with space),
  // causing the `)` to wrap onto a separate line under display:grid/flex parents.
  it('joins counter() with adjacent string token without source whitespace', async () => {
    const ol = document.createElement('ol')
    ol.className = 'reg-235-ol'
    const li = document.createElement('li')
    li.className = 'reg-235-li'
    li.textContent = 'item'
    ol.appendChild(li)
    document.body.appendChild(ol)

    const style = document.createElement('style')
    style.textContent = `
      .reg-235-ol { counter-reset: item; list-style: none; }
      .reg-235-li::before {
        counter-increment: item;
        content: counter(item) ")";
      }
    `
    document.head.appendChild(style)

    const cloneOl = ol.cloneNode(true)
    const cloneLi = cloneOl.firstElementChild
    await inlinePseudoElements(li, cloneLi, sessionCache, {})

    const before = cloneLi.querySelector('[data-snapdom-pseudo="::before"]')
    expect(before).toBeTruthy()
    expect(before.textContent).toBe('1)')
  })

  // Speed punch-list: buildCounterContext walks the whole document, so it must
  // only pay that cost when a pseudo actually declares counter-reset/-increment
  // or a counter()/counters() content value — not on every pseudo-element check.
  it('never walks the document for counter state when no pseudo uses counters', async () => {
    const el = document.createElement('div')
    el.className = 'no-counters-el'
    document.body.appendChild(el)
    const style = document.createElement('style')
    style.textContent = '.no-counters-el::before { content: "plain text"; }'
    document.head.appendChild(style)

    const localSessionCache = { styleMap: new Map(), styleCache: new WeakMap() }
    const clone = el.cloneNode(true)
    await inlinePseudoElements(el, clone, localSessionCache, {})

    expect(buildCounterContext).not.toHaveBeenCalled()
  })

  it('builds the document counter context lazily, once, only when a pseudo needs it', async () => {
    const ol = document.createElement('ol')
    ol.className = 'lazy-ctx-ol'
    const li1 = document.createElement('li')
    li1.className = 'lazy-ctx-li'
    const li2 = document.createElement('li')
    li2.className = 'lazy-ctx-li'
    ol.append(li1, li2)
    document.body.appendChild(ol)
    const style = document.createElement('style')
    style.textContent = `
      .lazy-ctx-ol { counter-reset: item; list-style: none; }
      .lazy-ctx-li::before { counter-increment: item; content: counter(item); }
    `
    document.head.appendChild(style)

    const localSessionCache = { styleMap: new Map(), styleCache: new WeakMap() }
    const cloneOl = ol.cloneNode(true)
    await inlinePseudoElements(li1, cloneOl.children[0], localSessionCache, {})
    await inlinePseudoElements(li2, cloneOl.children[1], localSessionCache, {})

    expect(buildCounterContext).toHaveBeenCalledTimes(1)
  })

  // #19: a pseudo's own counter-set must override the counter value before resolving content.
  it('applies counter-set on a pseudo element', async () => {
    const ol = document.createElement('ol')
    ol.className = 'cset-ol'
    const li = document.createElement('li')
    li.className = 'cset-li'
    li.textContent = 'x'
    ol.appendChild(li)
    document.body.appendChild(ol)

    const style = document.createElement('style')
    style.textContent = `
      .cset-ol { counter-reset: item; list-style: none; }
      .cset-li::before {
        counter-set: item 41;
        content: counter(item);
      }
    `
    document.head.appendChild(style)

    const cloneOl = ol.cloneNode(true)
    const cloneLi = cloneOl.firstElementChild
    await inlinePseudoElements(li, cloneLi, sessionCache, {})

    const before = cloneLi.querySelector('[data-snapdom-pseudo="::before"]')
    expect(before).toBeTruthy()
    expect(before.textContent).toBe('41')

    ol.remove()
    style.remove()
  })

  // #419: a single-side border (border-bottom) on a pseudo must be rendered. The shorthand
  // border-width "0px 0px 1px 0px" parsed as parseFloat→0 made the pseudo look border-less,
  // so an empty-content pseudo (no other reason to render) was dropped entirely.
  it('renders an empty-content pseudo that only has a border-bottom', async () => {
    const el = document.createElement('div')
    el.className = 'b419-empty'
    el.textContent = 'host'
    document.body.appendChild(el)

    const style = document.createElement('style')
    style.textContent = `
      .b419-empty::before {
        content: "";
        position: absolute;
        bottom: 0;
        width: 100%;
        border-bottom: 1px solid red;
      }
    `
    document.head.appendChild(style)

    const clone = el.cloneNode(true)
    await inlinePseudoElements(el, clone, { styleMap: new Map(), styleCache: new WeakMap() }, {})

    const before = clone.querySelector('[data-snapdom-pseudo="::before"]')
    expect(before).toBeTruthy() // was dropped before the fix

    el.remove()
    style.remove()
  })

  it('keeps the border-bottom in the snapshot of a content+border pseudo', async () => {
    const el = document.createElement('div')
    el.className = 'b419-text'
    el.textContent = 'host'
    document.body.appendChild(el)

    const style = document.createElement('style')
    style.textContent = `
      .b419-text::before {
        content: "B";
        border-bottom: 1px solid red;
      }
    `
    document.head.appendChild(style)

    const sessionCache2 = { styleMap: new Map(), styleCache: new WeakMap() }
    const clone = el.cloneNode(true)
    await inlinePseudoElements(el, clone, sessionCache2, {})

    const before = clone.querySelector('[data-snapdom-pseudo="::before"]')
    expect(before).toBeTruthy()
    const key = sessionCache2.styleMap.get(before) || ''
    expect(key).toMatch(/border-bottom-width:\s*1px/)
    expect(key).toMatch(/border-bottom-style:\s*solid/)

    el.remove()
    style.remove()
  })

  // #418: antd centers a modal with an empty `::before` spacer (display:inline-block;
  // height:100%; vertical-align:middle). It paints nothing, so it was dropped and the
  // vertical centering collapsed. A box-generating pseudo with real size must be kept.
  it('keeps an empty box-generating pseudo used as a layout spacer', async () => {
    const wrap = document.createElement('div')
    wrap.className = 'centerer'
    wrap.textContent = 'modal'
    document.body.appendChild(wrap)

    const style = document.createElement('style')
    style.textContent = `
      .centerer { height: 200px; text-align: center; }
      .centerer::before {
        content: "";
        display: inline-block;
        width: 0;
        height: 100%;
        vertical-align: middle;
      }
    `
    document.head.appendChild(style)

    const sessionCache3 = { styleMap: new Map(), styleCache: new WeakMap() }
    const clone = wrap.cloneNode(true)
    await inlinePseudoElements(wrap, clone, sessionCache3, {})

    const before = clone.querySelector('[data-snapdom-pseudo="::before"]')
    expect(before).toBeTruthy() // was dropped before the fix → centering collapsed
    const key = sessionCache3.styleMap.get(before) || ''
    expect(key).toMatch(/display:\s*inline-block/)
    // Gecko: vertical-align es shorthand; la key captura el longhand alignment-baseline
    expect(key).toMatch(/vertical-align:\s*middle|alignment-baseline:\s*middle/)

    wrap.remove()
    style.remove()
  })

  // Bug-hunt finding: content: image-set(...) only matched content.startsWith('url(') and
  // fell through to the plain-text branch, rendering the raw CSS source as visible text
  // instead of painting an image — confirmed live via getComputedStyle before this fix.
  it('renders content: image-set(...) as an image, not literal text', async () => {
    const PX1 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=='
    const PX2 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgAAIAAAUAAen63NgAAAAASUVORK5CYII='

    const el = document.createElement('div')
    el.className = 'imageset-target'
    document.body.appendChild(el)

    const style = document.createElement('style')
    style.textContent = `
      .imageset-target::before {
        content: image-set(url("${PX1}") 1x, url("${PX2}") 2x);
        display: inline-block;
        width: 20px;
        height: 20px;
      }
    `
    document.head.appendChild(style)

    const sessionCache4 = { styleMap: new Map(), styleCache: new WeakMap() }
    const clone = el.cloneNode(true)
    await inlinePseudoElements(el, clone, sessionCache4, {})

    const before = clone.querySelector('[data-snapdom-pseudo="::before"]')
    expect(before).toBeTruthy()
    const img = before.querySelector('img')
    expect(img).toBeTruthy()
    expect(img.src).toMatch(/^data:image\/png/)
    // Must not have fallen through to the plain-text branch.
    expect(before.textContent).toBe('')

    el.remove()
    style.remove()
  })
})

describe('quote keywords in pseudo content (fidelity: literal "open-quote" bug)', () => {
  afterEach(() => {
    document.head.querySelectorAll('style[data-quote-test]').forEach((s) => s.remove())
    document.body.innerHTML = ''
  })

  async function captureSvg(el) {
    const { snapdom } = await import('../src/api/snapdom.js')
    const res = await snapdom(el, { cache: 'disabled' })
    // Rendered markup only — the generated class CSS legitimately carries the raw
    // `content: open-quote` declaration (it never paints; only the span text does).
    return decodeURIComponent(res.url.split(',')[1]).replace(/<style[\s\S]*?<\/style>/g, '')
  }

  it('resolves open/close-quote from the computed quotes pairs', async () => {
    const style = document.createElement('style')
    style.setAttribute('data-quote-test', '')
    style.textContent = `
      .zz-q { quotes: "«" "»"; }
      .zz-q::before { content: open-quote; }
      .zz-q::after { content: close-quote; }
    `
    document.head.appendChild(style)
    const el = document.createElement('div')
    el.className = 'zz-q'
    el.textContent = 'cita'
    document.body.appendChild(el)
    const svg = await captureSvg(el)
    expect(svg).toContain('«')
    expect(svg).toContain('»')
    expect(svg).not.toContain('open-quote')
  })

  it('falls back to typographic quotes when quotes computes auto, and renders nothing for no-*-quote', async () => {
    const style = document.createElement('style')
    style.setAttribute('data-quote-test', '')
    style.textContent = `
      .zz-q2::before { content: open-quote; }
      .zz-q2::after { content: no-close-quote; }
    `
    document.head.appendChild(style)
    const el = document.createElement('div')
    el.className = 'zz-q2'
    el.textContent = 'hola'
    document.body.appendChild(el)
    const svg = await captureSvg(el)
    expect(svg).toContain('“')
    expect(svg).not.toContain('open-quote')
    expect(svg).not.toContain('no-close-quote')
  })
})

describe('scoped ::marker and ::first-line rules', () => {
  afterEach(() => {
    document.head.querySelectorAll('style[data-mk-test]').forEach((s) => s.remove())
    document.body.innerHTML = ''
  })

  async function captureSvg(el) {
    const { snapdom } = await import('../src/api/snapdom.js')
    const res = await snapdom(el, { cache: 'disabled' })
    return decodeURIComponent(res.url.split(',')[1])
  }

  it('authored ::marker color/content survives as a scoped rule', async () => {
    const style = document.createElement('style')
    style.setAttribute('data-mk-test', '')
    style.textContent = '.zz-list li::marker { color: rgb(200, 10, 10); content: "→ "; }'
    document.head.appendChild(style)
    const ul = document.createElement('ul')
    ul.className = 'zz-list'
    ul.innerHTML = '<li>uno</li><li>dos</li>'
    document.body.appendChild(ul)
    const svg = await captureSvg(ul)
    expect(svg).toContain('::marker')
    expect(svg).toContain('rgb(200, 10, 10)')
    expect(svg).toContain('data-sd-p')
  })

  it('authored ::first-line styling survives as a scoped rule', async () => {
    const style = document.createElement('style')
    style.setAttribute('data-mk-test', '')
    style.textContent = '.zz-lede::first-line { font-weight: 700; color: rgb(10, 10, 200); }'
    document.head.appendChild(style)
    const p = document.createElement('p')
    p.className = 'zz-lede'
    p.style.width = '120px'
    p.textContent = 'primera línea de un párrafo largo que envuelve en varias líneas seguro'
    document.body.appendChild(p)
    const svg = await captureSvg(p)
    expect(svg).toContain('::first-line')
    expect(svg).toContain('rgb(10, 10, 200)')
  })

  it('default markers emit no scoped rule (zero output cost)', async () => {
    const ul = document.createElement('ul')
    ul.innerHTML = '<li>plain</li>'
    document.body.appendChild(ul)
    const svg = await captureSvg(ul)
    expect(svg).not.toContain('::marker')
  })

  // The selector gate feeds on raw `rule.selectorText`, which under CSS nesting is
  // `& .feat::before` — a selector matches() answers FALSE to instead of throwing, so the
  // try/catch fallback never fires and the pseudo is gated out of existence. Asserted on
  // painted pixels: the content string survives inside the <style> payload either way.
  it('CSS-nested pseudo rules still paint', async () => {
    async function pinkPixels(host) {
      const { snapdom } = await import('../src/api/snapdom.js')
      const res = await snapdom(host, { cache: 'disabled', dpr: 1, scale: 1 })
      const c = await res.toCanvas()
      const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data
      let n = 0
      for (let i = 0; i < d.length; i += 4) {
        if (d[i] > 180 && d[i + 1] < 80 && d[i + 2] < 110 && d[i + 3] > 40) n++
      }
      return n
    }
    const shapes = {
      'flat (control)': '.zzp .feat::before { content:"✔✔✔"; color:rgb(225,29,72); font-size:20px }',
      'nested descendant': '.zzp { color:#111; & .feat::before { content:"✔✔✔"; color:rgb(225,29,72); font-size:20px } }',
      'nested inside @media': '@media (min-width:1px){ .zzp { & .feat::before { content:"✔✔✔"; color:rgb(225,29,72); font-size:20px } } }',
    }
    for (const [label, css] of Object.entries(shapes)) {
      const style = document.createElement('style')
      style.setAttribute('data-mk-test', '')
      style.textContent = css
      document.head.appendChild(style)
      const host = document.createElement('div')
      host.innerHTML = '<div class="zzp">plan <span class="feat">feature</span></div>'
      document.body.appendChild(host)
      const painted = await pinkPixels(host)
      style.remove()
      host.remove()
      expect(painted, label).toBeGreaterThan(20)
    }
  })

  // The scoped-rule emitter runs inside inlinePseudoElements, which the preflight can
  // skip wholesale. A page whose ONLY author pseudo is a marker/first-line has to keep
  // the pass alive — asserted on a private document so an unrelated ::before anywhere
  // in the test page can't mask a regression.
  it('preflight follows @import (component CSS often lives one sheet down)', async () => {
    const { shouldProcessPseudos } = await import('../src/modules/pseudo.js')
    const frame = document.createElement('iframe')
    const blob = new Blob(['.feat::before{content:"✔";color:red}'], { type: 'text/css' })
    const url = URL.createObjectURL(blob)
    try {
      // Read the final document only after its imported stylesheet has loaded.
      await new Promise((resolve) => {
        frame.onload = resolve
        frame.srcdoc = `<!doctype html><html><head><style>@import url("${url}"); .x{color:#111}</style></head><body></body></html>`
        document.body.appendChild(frame)
      })
      const doc = frame.contentDocument
      expect(doc.styleSheets[0].cssRules[0].styleSheet.cssRules.length).toBeGreaterThan(0)
      expect(shouldProcessPseudos(doc)).toBe(true)
    } finally {
      URL.revokeObjectURL(url)
      frame.remove()
    }
  })

  it('preflight keeps the pseudo pass for marker/first-line-only pages', async () => {
    const { shouldProcessPseudos } = await import('../src/modules/pseudo.js')
    const frame = document.createElement('iframe')
    document.body.appendChild(frame)
    const doc = frame.contentDocument
    try {
      doc.head.innerHTML = '<style>li::marker { color: rgb(200, 10, 10); }</style>'
      expect(shouldProcessPseudos(doc)).toBe(true)
      doc.head.innerHTML = '<style>.lede::first-line { font-weight: 700; }</style>'
      expect(shouldProcessPseudos(doc)).toBe(true)
      doc.head.innerHTML = '<style>.plain { color: red; }</style>'
      expect(shouldProcessPseudos(doc)).toBe(false)
    } finally {
      frame.remove()
    }
  })
})