// @vitest-environment jsdom
/**
 * test/gh-chip.test.ts (W3 PR + CI status chip) — render-only frontend module.
 *
 * Covers normalizePrStatus, chipText, renderPrChip, fetchPrStatus, mountPrChip.
 * Security: SEC-H4 — all chip content via textContent; a PR title with markup is
 * rendered as literal text (no element injection).
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { PrStatus } from '../src/types.js'

const {
  normalizePrStatus,
  chipText,
  renderPrChip,
  fetchPrStatus,
  mountPrChip,
} = await import('../public/gh-chip.js')

function okStatus(over: Partial<PrStatus> = {}): PrStatus {
  return {
    availability: 'ok',
    number: 12,
    title: 'A PR',
    url: 'https://github.com/o/r/pull/12',
    state: 'open',
    isDraft: false,
    mergeable: 'mergeable',
    checks: { total: 5, passing: 5, failing: 0, pending: 0 },
    ...over,
  }
}

// ── normalizePrStatus ─────────────────────────────────────────────────────────

describe('normalizePrStatus', () => {
  it('round-trips a valid ok object', () => {
    const s = normalizePrStatus(okStatus())
    expect(s).not.toBeNull()
    expect(s!.availability).toBe('ok')
    expect(s!.number).toBe(12)
    expect(s!.state).toBe('open')
    expect(s!.mergeable).toBe('mergeable')
    expect(s!.checks).toEqual({ total: 5, passing: 5, failing: 0, pending: 0 })
  })

  it('accepts every degrade availability', () => {
    for (const availability of ['no-pr', 'not-installed', 'unauthenticated', 'disabled', 'error']) {
      expect(normalizePrStatus({ availability })?.availability).toBe(availability)
    }
  })

  it('returns null for a non-object or an unknown availability', () => {
    expect(normalizePrStatus(null)).toBeNull()
    expect(normalizePrStatus(42)).toBeNull()
    expect(normalizePrStatus('ok')).toBeNull()
    expect(normalizePrStatus({})).toBeNull()
    expect(normalizePrStatus({ availability: 'bogus' })).toBeNull()
  })

  it('drops malformed optional fields but keeps availability', () => {
    const s = normalizePrStatus({ availability: 'ok', number: 'nope', state: 'weird', checks: 5 })
    expect(s?.availability).toBe('ok')
    expect(s?.number).toBeUndefined()
    expect(s?.state).toBeUndefined()
    expect(s?.checks).toBeUndefined()
  })
})

// ── chipText ──────────────────────────────────────────────────────────────────

describe('chipText', () => {
  it('renders an open PR with all checks passing', () => {
    const t = chipText(okStatus())!
    expect(t.label).toBe('PR #12 ✓ 5/5')
    expect(t.cls).toContain('proj-pr-open')
    expect(t.cls).toContain('proj-pr-checks-ok')
  })

  it('marks failing checks with ✕ and the fail class', () => {
    const t = chipText(okStatus({ checks: { total: 5, passing: 3, failing: 2, pending: 0 } }))!
    expect(t.label).toBe('PR #12 ✕ 3/5')
    expect(t.cls).toContain('proj-pr-checks-fail')
  })

  it('marks pending checks with ⧗ and the pending class', () => {
    const t = chipText(okStatus({ checks: { total: 4, passing: 1, failing: 0, pending: 3 } }))!
    expect(t.label).toBe('PR #12 ⧗ 1/4')
    expect(t.cls).toContain('proj-pr-checks-pending')
  })

  it('adds a "⚠ conflicts" marker + class for a conflicting PR', () => {
    const t = chipText(okStatus({ mergeable: 'conflicting' }))!
    expect(t.label).toContain('⚠ conflicts')
    expect(t.cls).toContain('proj-pr-conflict')
  })

  it('labels a draft PR', () => {
    const t = chipText(okStatus({ isDraft: true }))!
    expect(t.label).toContain('Draft #12')
    expect(t.cls).toContain('proj-pr-draft')
  })

  it('labels merged / closed PRs', () => {
    expect(chipText(okStatus({ state: 'merged' }))!.label).toContain('Merged #12')
    expect(chipText(okStatus({ state: 'closed' }))!.cls).toContain('proj-pr-closed')
  })

  it('omits the checks segment when there are zero checks', () => {
    const t = chipText(okStatus({ checks: { total: 0, passing: 0, failing: 0, pending: 0 } }))!
    expect(t.label).toBe('PR #12')
  })

  it('maps each degrade availability to its label', () => {
    expect(chipText({ availability: 'no-pr' })!.label).toBe('No PR')
    const notInstalled = chipText({ availability: 'not-installed' })!
    expect(notInstalled.label).toBe('gh not installed')
    expect(notInstalled.title).toContain('cli.github.com')
    expect(chipText({ availability: 'unauthenticated' })!.label).toBe('gh auth login')
    expect(chipText({ availability: 'error' })!.label).toBe('PR status unavailable')
  })

  it('returns null for a disabled status (chip hidden)', () => {
    expect(chipText({ availability: 'disabled' })).toBeNull()
  })
})

// ── renderPrChip ──────────────────────────────────────────────────────────────

describe('renderPrChip', () => {
  it('renders a chip element with the label and state class', () => {
    const chip = renderPrChip(okStatus())
    expect(chip.className).toContain('proj-pr-chip')
    expect(chip.className).toContain('proj-pr-open')
    expect(chip.querySelector('.proj-pr-label')?.textContent).toBe('PR #12 ✓ 5/5')
  })

  it('renders a hidden chip for a disabled status', () => {
    const chip = renderPrChip({ availability: 'disabled' })
    expect(chip.style.display).toBe('none')
    expect(chip.querySelector('.proj-pr-label')).toBeNull()
  })

  it('renders an attacker PR title as LITERAL text — no element injection (SEC-H4)', () => {
    const evil = '<img src=x onerror=alert(1)>'
    const chip = renderPrChip(okStatus({ title: evil }))
    expect(chip.textContent).toContain(evil)
    expect(chip.querySelector('img')).toBeNull()
    // The tooltip attribute is also inert (attributes are never HTML-parsed).
    expect(chip.title).toBe(evil)
  })
})

// ── fetchPrStatus ─────────────────────────────────────────────────────────────

describe('fetchPrStatus', () => {
  afterEach(() => {
    vi.unstubAllGlobals()
  })

  it('normalizes a 200 JSON response', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(async () => ({ ok: true, json: async () => okStatus() })),
    )
    const s = await fetchPrStatus('/p/repo')
    expect(s?.availability).toBe('ok')
    expect(s?.number).toBe(12)
  })

  it('returns null on a non-ok response', async () => {
    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })))
    expect(await fetchPrStatus('/p/repo')).toBeNull()
  })

  it('returns null when fetch rejects (never throws)', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(async () => {
        throw new Error('network down')
      }),
    )
    expect(await fetchPrStatus('/p/repo')).toBeNull()
  })
})

// ── mountPrChip ───────────────────────────────────────────────────────────────

describe('mountPrChip', () => {
  afterEach(() => {
    vi.unstubAllGlobals()
  })

  let host: HTMLElement
  beforeEach(() => {
    host = document.createElement('span')
    document.body.append(host)
  })

  it('shows a loading placeholder, then swaps in the resolved chip', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(async () => ({ ok: true, json: async () => okStatus() })),
    )
    const handle = mountPrChip(host, '/p/repo')
    expect(host.querySelector('.proj-pr-loading')).not.toBeNull()
    await new Promise((r) => setTimeout(r, 0))
    expect(host.querySelector('.proj-pr-loading')).toBeNull()
    expect(host.querySelector('.proj-pr-open')).not.toBeNull()
    handle.destroy()
  })

  it('degrades to an error chip when fetch rejects (no throw)', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(async () => {
        throw new Error('boom')
      }),
    )
    mountPrChip(host, '/p/repo')
    await new Promise((r) => setTimeout(r, 0))
    expect(host.querySelector('.proj-pr-unavailable')).not.toBeNull()
    expect(host.textContent).toContain('PR status unavailable')
  })

  it('destroy() removes the chip node', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(async () => ({ ok: true, json: async () => okStatus() })),
    )
    const handle = mountPrChip(host, '/p/repo')
    handle.destroy()
    await new Promise((r) => setTimeout(r, 0))
    expect(host.childNodes.length).toBe(0)
  })
})
