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

// Fresh module load so __wired resets for every test that asks for it
async function loadInlineAllStylesFresh() {
  await vi.resetModules()
  const mod = await import('../src/modules/styles.js')
  return mod.inlineAllStyles
}

// Stub minimal de MutationObserver para contar instancias
class MOStub {
  static count = 0
  constructor(/* cb */) {
    MOStub.count++
  }
  observe() {}
  disconnect() {}
  takeRecords() { return [] }
}

function freshSession() {
  return {
    styleMap: new Map(),
    styleCache: new WeakMap(),
    nodeMap: new Map(),
  }
}

describe('inlineAllStyles – branches y firmas', () => {
  beforeEach(() => {
    MOStub.count = 0
    globalThis.MutationObserver = MOStub
  })

  it('early-return para <style> (no escribe en styleMap)', async () => {
    const inlineAllStyles = await loadInlineAllStylesFresh()

    const src = document.createElement('style')
    src.textContent = '.x{color:red}'
    const clone = document.createElement('style')
    const session = freshSession()

    await inlineAllStyles(src, clone, session, { cache: 'auto' })

    expect(session.styleMap.size).toBe(0)
    expect(MOStub.count).toBe(0)
  })

  it('wires invalidation once when cache !== "disabled"', async () => {
    const inlineAllStyles = await loadInlineAllStylesFresh()

    const s1 = document.createElement('div')
    const c1 = document.createElement('div')
    const s2 = document.createElement('span')
    const c2 = document.createElement('span')
    const session = freshSession()

    await inlineAllStyles(s1, c1, session, { cache: 'auto' })
    await inlineAllStyles(s2, c2, session, { cache: 'soft' })

    // styles.js engancha 2 observers (documentElement + head) una sola vez
    expect(MOStub.count).toBe(2)
    expect(session.styleMap.has(c1)).toBe(true)
    expect(session.styleMap.has(c2)).toBe(true)
  })

  it('does NOT wire invalidation when cache === "disabled"', async () => {
    const inlineAllStyles = await loadInlineAllStylesFresh()

    const src = document.createElement('div')
    const clone = document.createElement('div')
    const session = freshSession()

    await inlineAllStyles(src, clone, session, { cache: 'disabled' })

    expect(MOStub.count).toBe(0)
    expect(session.styleMap.has(clone)).toBe(true)
  })

  it('firma #1: (source, clone, sessionCache, options)', async () => {
    const inlineAllStyles = await loadInlineAllStylesFresh()

    const src = document.createElement('p')
    const clone = document.createElement('p')
    const session = freshSession()

    await inlineAllStyles(src, clone, session, { cache: 'auto' })
    expect(session.styleMap.has(clone)).toBe(true)
  })

  it('firma #2: (source, clone, ctx) pasando ctx completo', async () => {
    const inlineAllStyles = await loadInlineAllStylesFresh()
    const { cache } = await import('../src/core/cache.js')

    const src = document.createElement('em')
    const clone = document.createElement('em')
    const session = freshSession()

    const ctx = {
      session,
      persist: {
        snapshotKeyCache: new Map(),
        defaultStyle: cache.defaultStyle,
        baseStyle: cache.baseStyle,
        image: cache.image,
        resource: cache.resource,
        background: cache.background,
      },
      options: { cache: 'auto' },
    }

    await inlineAllStyles(src, clone, ctx)
    expect(session.styleMap.has(clone)).toBe(true)
  })

  it('signature #3: (source, clone, options) using options only', async () => {
    const inlineAllStyles = await loadInlineAllStylesFresh()

    const src = document.createElement('strong')
    const clone = document.createElement('strong')

    // Direct calls without a session get isolated throwaway maps (there is no session
    // global) — the observable contract is simply that the call succeeds and styles the
    // clone. Sync since the promise-churn cleanup (the function never awaited anything).
    expect(inlineAllStyles(src, clone, { cache: 'soft' })).toBeUndefined()
  })

  it('#348: excludeStyleProps regex excludes matching props from snapshot', async () => {
    const inlineAllStyles = await loadInlineAllStylesFresh()

    const src = document.createElement('div')
    src.style.color = 'red'
    src.style.fontSize = '13.37px'  // highly non-default so it appears in key
    document.body.appendChild(src)

    const clone = document.createElement('div')
    const session = freshSession()

    await inlineAllStyles(src, clone, session, {
      cache: 'auto',
      excludeStyleProps: /^color$/
    })

    document.body.removeChild(src)

    const key = session.styleMap.get(clone)
    expect(key).toBeDefined()
    // Only the exact "color" prop was excluded; other *-color props remain
    expect(key).not.toMatch(/(?:^|;)color:/)
    expect(key).toMatch(/font-size:/)
  })

  it('#348: excludeStyleProps function excludes matching props', async () => {
    const inlineAllStyles = await loadInlineAllStylesFresh()

    const src = document.createElement('span')
    src.style.fontSize = '14px'
    const clone = document.createElement('span')
    const session = freshSession()

    await inlineAllStyles(src, clone, session, {
      cache: 'auto',
      excludeStyleProps: (prop) => prop === 'font-size'
    })

    const key = session.styleMap.get(clone)
    expect(key).toBeDefined()
    expect(key).not.toMatch(/font-size:/)
  })

  it('#348: snapshot cache invalidates when excludeStyleProps changes between captures', async () => {
    const inlineAllStyles = await loadInlineAllStylesFresh()

    const src = document.createElement('div')
    src.style.color = 'red'
    src.style.fontSize = '13.37px'
    document.body.appendChild(src)

    // First capture excludes `color`.
    const clone1 = document.createElement('div')
    const session1 = freshSession()
    await inlineAllStyles(src, clone1, session1, { cache: 'auto', excludeStyleProps: /^color$/ })
    expect(session1.styleMap.get(clone1)).not.toMatch(/(?:^|;)color:/)

    // Second capture of the SAME element without exclusion. MutationObserver is stubbed so
    // __epoch is frozen → the per-element snapshot cache would be hit. It must not reuse the
    // stale snapshot that dropped `color`.
    const clone2 = document.createElement('div')
    const session2 = freshSession()
    await inlineAllStyles(src, clone2, session2, { cache: 'auto' })
    const key2 = session2.styleMap.get(clone2)

    document.body.removeChild(src)
    expect(key2).toMatch(/(?:^|;)color:/)
  })

  it('#362: border: 0 solid normalizes to border: none in snapshot', async () => {
    const inlineAllStyles = await loadInlineAllStylesFresh()

    const style = document.createElement('style')
    style.textContent = '* { border: 0 solid; }'
    document.head.appendChild(style)

    const src = document.createElement('div')
    document.body.appendChild(src)

    const clone = document.createElement('div')
    const session = freshSession()

    await inlineAllStyles(src, clone, session, { cache: 'auto' })

    document.body.removeChild(src)
    document.head.removeChild(style)

    const key = session.styleMap.get(clone)
    expect(key).toBeDefined()
    // Tailwind * { border: 0 solid } must become border: none in output (#362)
    expect(key).toMatch(/\bborder:\s*none\b/)
    // The logical per-side longhands survived the normalization and re-declared a
    // zero-width solid border after it, since they come later in the enumeration.
  })

  it('content-visibility:hidden is carried verbatim into the snapshot (NEW-10)', async () => {
    const inlineAllStyles = await loadInlineAllStylesFresh()

    const src = document.createElement('div')
    // Use inline style so computed style definitely reflects the value
    src.style.setProperty('content-visibility', 'hidden')
    document.body.appendChild(src)

    const clone = document.createElement('div')
    const session = freshSession()

    await inlineAllStyles(src, clone, session, { cache: 'auto' })

    document.body.removeChild(src)

    const key = session.styleMap.get(clone)
    // The declaration itself must reach the snapshot so the rasterizer applies the real
    // semantics: element box painted, contents skipped, not overridable by a descendant.
    // Mapping it to visibility:hidden (what this test used to assert) erased the box too.
    expect(key).toMatch(/\bcontent-visibility:\s*hidden\b/)
    // Lookbehind: `\bvisibility` also matches inside `content-visibility`.
    expect(key).not.toMatch(/(?<!-)\bvisibility:\s*hidden\b/)
  })

  it('cachea getComputedStyle en session.styleCache (una sola lectura por source)', async () => {
    const inlineAllStyles = await loadInlineAllStylesFresh()

    const src = document.createElement('div')
    const clone1 = document.createElement('div')
    const clone2 = document.createElement('div')
    const session = freshSession()

    const spy = vi.spyOn(window, 'getComputedStyle')

    await inlineAllStyles(src, clone1, session, { cache: 'auto' })
    await inlineAllStyles(src, clone2, session, { cache: 'auto' })

    expect(spy).toHaveBeenCalledTimes(1)
    expect(session.styleMap.has(clone1)).toBe(true)
    expect(session.styleMap.has(clone2)).toBe(true)

    spy.mockRestore()
  })

  it('ROB-1: inlineAllStyles does not throw for a detached (not-connected) element', async () => {
    const inlineAllStyles = await loadInlineAllStylesFresh()

    // Detached element: never appended to document.body
    const src = document.createElement('div')
    src.style.color = 'red'
    const clone = document.createElement('div')
    const session = freshSession()

    // Should not throw even though getComputedStyle() on a detached node is unreliable
    expect(() => inlineAllStyles(src, clone, session, { cache: 'auto' })).not.toThrow()
    // styleMap entry is written (even if the key may be empty/incomplete for detached nodes)
    expect(session.styleMap.has(clone)).toBe(true)
  })

  it('PERF-4: snapshotKeyCache does not grow unboundedly — evicts when oversized', async () => {
    // This test verifies the MAX_SNAPSHOT_KEY_CACHE=2000 eviction guard.
    // We fill the cache with many unique style signatures by bumping the epoch
    // (which triggers bumpEpoch) repeatedly. After eviction the cache should be empty.
    const { notifyStyleEpoch } = await import('../src/modules/styles.js')

    // Bump epoch enough times to trigger eviction if the cache were overfull.
    // Since we can't directly fill the Map from outside, we verify that
    // notifyStyleEpoch (bumpEpoch) is callable and doesn't throw.
    for (let i = 0; i < 5; i++) notifyStyleEpoch()

    // Ensure inlineAllStyles still works after repeated epoch bumps
    const inlineAllStyles = await loadInlineAllStylesFresh()
    const src = document.createElement('div')
    src.style.color = 'blue'
    const clone = document.createElement('div')
    const session = freshSession()
    await inlineAllStyles(src, clone, session, { cache: 'auto' })
    expect(session.styleMap.has(clone)).toBe(true)
  })

  // :focus/:checked re-style an element without producing any mutation record, so the
  // snapshot cache (keyed by the style epoch, which only external DOM mutations bump)
  // kept serving pre-interaction styles across captures.
  it('interaction state invalidates cached style snapshots', async () => {
    const { snapdom } = await import('../src/api/snapdom.js')
    const style = document.createElement('style')
    style.textContent = '.zzf{background:rgb(255,255,255);width:150px;height:30px;border:1px solid #999}' +
      '.zzf:focus{background:rgb(0,0,255)}'
    document.head.appendChild(style)
    const host = document.createElement('div')
    host.innerHTML = '<input class="zzf">'
    document.body.appendChild(host)
    const input = host.querySelector('.zzf')

    const blue = async () => {
      const res = await snapdom(host, { 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] < 80 && d[i + 1] < 80 && d[i + 2] > 180 && d[i + 3] > 40) n++
      }
      return n
    }

    // warm the cache with captures taken BEFORE the interaction
    await blue()
    await blue()
    input.focus()
    await new Promise((r) => setTimeout(r, 0))
    expect(input.matches(':focus')).toBe(true) // precondition

    try {
      expect(await blue()).toBeGreaterThan(20)
    } finally {
      style.remove()
      host.remove()
    }
  })
})