// @vitest-environment jsdom
/**
 * test/diff.test.ts — N-diff-ui: diff viewer render-only frontend module
 *
 * Covers: normalizeDiffResult, fetchDiff, renderDiffFile, renderDiff,
 * mountDiffViewer (working/staged toggle, truncated warning, empty state).
 *
 * Security: SEC-H4 — all diff content via textContent, zero innerHTML.
 * Accept: AC-B1.3, AC-B1.4
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { DiffResult, DiffFile, DiffLine, DiffHunk } from '../src/types.js'

// ── module import ─────────────────────────────────────────────────────────────
const {
  normalizeDiffResult,
  fetchDiff,
  renderDiffFile,
  renderDiff,
  mountDiffViewer,
} = await import('../public/diff.js')

// ── helpers ───────────────────────────────────────────────────────────────────

function makeLine(kind: DiffLine['kind'], text: string): DiffLine {
  return { kind, text }
}

function makeHunk(header: string, lines: DiffLine[] = []): DiffHunk {
  return { header, lines }
}

function makeFile(over: Partial<DiffFile> = {}): DiffFile {
  return {
    oldPath: 'src/foo.ts',
    newPath: 'src/foo.ts',
    status: 'modified',
    added: 2,
    removed: 1,
    binary: false,
    hunks: [makeHunk('@@ -1,3 +1,4 @@', [
      makeLine('context', ' line1'),
      makeLine('removed', '-old'),
      makeLine('added', '+new'),
      makeLine('added', '+extra'),
    ])],
    ...over,
  }
}

function makeResult(over: Partial<DiffResult> = {}): DiffResult {
  return {
    files: [makeFile()],
    staged: false,
    truncated: false,
    ...over,
  }
}

beforeEach(() => {
  vi.restoreAllMocks()
})

afterEach(() => {
  vi.restoreAllMocks()
})

// ── normalizeDiffResult ───────────────────────────────────────────────────────

describe('normalizeDiffResult', () => {
  it('returns null for null input', () => {
    expect(normalizeDiffResult(null)).toBeNull()
  })

  it('returns null for non-object input', () => {
    expect(normalizeDiffResult('string')).toBeNull()
    expect(normalizeDiffResult(42)).toBeNull()
    expect(normalizeDiffResult(undefined)).toBeNull()
  })

  it('returns null when files is not an array', () => {
    expect(normalizeDiffResult({ files: 'bad', staged: false, truncated: false })).toBeNull()
  })

  it('returns null when staged is not a boolean', () => {
    expect(normalizeDiffResult({ files: [], staged: 'yes', truncated: false })).toBeNull()
  })

  it('returns null when truncated is not a boolean', () => {
    expect(normalizeDiffResult({ files: [], staged: false, truncated: 'yes' })).toBeNull()
  })

  it('returns a valid DiffResult for a well-formed object', () => {
    const raw = makeResult()
    const result = normalizeDiffResult(raw)
    expect(result).not.toBeNull()
    expect(result?.staged).toBe(false)
    expect(result?.truncated).toBe(false)
    expect(result?.files).toHaveLength(1)
  })

  it('filters out invalid file entries (keeps only valid ones)', () => {
    const raw = {
      files: [
        makeFile(),
        { not: 'a file' },
        null,
      ],
      staged: false,
      truncated: false,
    }
    const result = normalizeDiffResult(raw)
    expect(result?.files).toHaveLength(1)
  })

  it('accepts an empty files array (empty diff)', () => {
    const result = normalizeDiffResult({ files: [], staged: false, truncated: false })
    expect(result).not.toBeNull()
    expect(result?.files).toHaveLength(0)
  })

  it('returns DiffResult with staged=true when input has staged=true', () => {
    const result = normalizeDiffResult({ files: [], staged: true, truncated: false })
    expect(result?.staged).toBe(true)
  })

  it('returns DiffResult with truncated=true when input has truncated=true', () => {
    const result = normalizeDiffResult({ files: [], staged: false, truncated: true })
    expect(result?.truncated).toBe(true)
  })
})

// ── fetchDiff ─────────────────────────────────────────────────────────────────

describe('fetchDiff', () => {
  it('fetches from /projects/diff with correct query params (working tree)', async () => {
    const mockFetch = vi.fn(async () => ({
      ok: true,
      json: async () => makeResult({ staged: false }),
    }))
    vi.stubGlobal('fetch', mockFetch)

    const result = await fetchDiff('/some/repo', false)
    expect(mockFetch).toHaveBeenCalledOnce()
    const url = mockFetch.mock.calls[0]?.[0] as string
    expect(url).toContain('/projects/diff')
    expect(url).toContain(encodeURIComponent('/some/repo'))
    expect(url).toContain('staged=false')
    expect(result).not.toBeNull()
  })

  it('fetches with staged=true when staged is true', async () => {
    const mockFetch = vi.fn(async () => ({
      ok: true,
      json: async () => makeResult({ staged: true }),
    }))
    vi.stubGlobal('fetch', mockFetch)

    await fetchDiff('/repo', true)
    const url = mockFetch.mock.calls[0]?.[0] as string
    expect(url).toContain('staged=true')
  })

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

  it('returns null on fetch error (network failure)', async () => {
    vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
    const result = await fetchDiff('/repo', false)
    expect(result).toBeNull()
  })

  it('returns null when server returns invalid structure', async () => {
    vi.stubGlobal('fetch', vi.fn(async () => ({
      ok: true,
      json: async () => ({ not: 'a diff result' }),
    })))
    const result = await fetchDiff('/repo', false)
    expect(result).toBeNull()
  })
})

// ── renderDiffFile ────────────────────────────────────────────────────────────

describe('renderDiffFile', () => {
  it('renders the file path as plain text (newPath)', () => {
    const file = makeFile({ newPath: 'src/component.ts' })
    const el = renderDiffFile(file)
    expect(el.textContent).toContain('src/component.ts')
  })

  it('renders +/- stats badge with correct counts', () => {
    const file = makeFile({ added: 5, removed: 3 })
    const el = renderDiffFile(file)
    const text = el.textContent ?? ''
    expect(text).toContain('+5')
    expect(text).toContain('-3')
  })

  it('renders hunk header as text', () => {
    const file = makeFile({
      hunks: [makeHunk('@@ -1,3 +1,4 @@', [])],
    })
    const el = renderDiffFile(file)
    expect(el.textContent).toContain('@@ -1,3 +1,4 @@')
  })

  it('renders added/removed/context lines as plain text (SEC-H4)', () => {
    const file = makeFile({
      hunks: [makeHunk('@@ -1 +1 @@', [
        makeLine('added', '+new line'),
        makeLine('removed', '-old line'),
        makeLine('context', ' ctx line'),
      ])],
    })
    const el = renderDiffFile(file)
    const text = el.textContent ?? ''
    expect(text).toContain('+new line')
    expect(text).toContain('-old line')
    expect(text).toContain(' ctx line')
  })

  it('renders <script> content as plain text — not injected as HTML (SEC-H4)', () => {
    const xssLine = '<script>alert(1)</script>'
    const file = makeFile({
      hunks: [makeHunk('@@ -1 +1 @@', [
        makeLine('added', xssLine),
      ])],
    })
    const el = renderDiffFile(file)
    // The text should be present
    expect(el.textContent).toContain(xssLine)
    // But no actual <script> element should be injected
    expect(el.querySelectorAll('script').length).toBe(0)
  })

  it('renders & as literal text (not HTML entity expanded) (SEC-H4)', () => {
    const file = makeFile({
      hunks: [makeHunk('@@ -1 +1 @@', [
        makeLine('context', ' a & b && c'),
      ])],
    })
    const el = renderDiffFile(file)
    expect(el.textContent).toContain('a & b && c')
    // innerHTML should NOT contain unescaped &
    expect(el.innerHTML).not.toContain(' a & b ')
  })

  it('renders ANSI escape sequences as plain text (SEC-H4)', () => {
    const ansi = '\x1b[32m+green\x1b[0m'
    const file = makeFile({
      hunks: [makeHunk('@@ -1 +1 @@', [makeLine('added', ansi)])],
    })
    const el = renderDiffFile(file)
    expect(el.textContent).toContain(ansi)
  })

  it('renders a binary file indicator (no hunks)', () => {
    const file = makeFile({ binary: true, hunks: [] })
    const el = renderDiffFile(file)
    expect(el.textContent?.toLowerCase()).toContain('binary')
  })

  it('renders status for renamed file (shows old + new path)', () => {
    const file = makeFile({
      oldPath: 'src/old.ts',
      newPath: 'src/new.ts',
      status: 'renamed',
      hunks: [],
    })
    const el = renderDiffFile(file)
    const text = el.textContent ?? ''
    expect(text).toContain('src/old.ts')
    expect(text).toContain('src/new.ts')
  })

  it('renders deleted file with status indicator', () => {
    const file = makeFile({ status: 'deleted', hunks: [] })
    const el = renderDiffFile(file)
    expect(el.textContent?.toLowerCase()).toContain('deleted')
  })

  it('renders added (new) file with status indicator', () => {
    const file = makeFile({ status: 'added', hunks: [] })
    const el = renderDiffFile(file)
    expect(el.textContent?.toLowerCase()).toContain('added')
  })

  it('returns an HTMLElement (not null)', () => {
    expect(renderDiffFile(makeFile())).toBeInstanceOf(HTMLElement)
  })
})

// ── renderDiff ────────────────────────────────────────────────────────────────

describe('renderDiff', () => {
  it('renders each file from the result', () => {
    const result = makeResult({
      files: [
        makeFile({ newPath: 'a.ts' }),
        makeFile({ newPath: 'b.ts' }),
      ],
    })
    const el = renderDiff(result)
    expect(el.textContent).toContain('a.ts')
    expect(el.textContent).toContain('b.ts')
  })

  it('shows empty state message when no files changed', () => {
    const result = makeResult({ files: [] })
    const el = renderDiff(result)
    // Should show some empty-state text
    const text = el.textContent?.toLowerCase() ?? ''
    expect(text).toMatch(/no (changes|diff|files)/i)
  })

  it('shows truncated warning when result.truncated is true', () => {
    const result = makeResult({ truncated: true })
    const el = renderDiff(result)
    const text = el.textContent?.toLowerCase() ?? ''
    expect(text).toMatch(/truncated|too large|large/i)
  })

  it('does NOT show truncated warning when result.truncated is false', () => {
    const result = makeResult({ truncated: false })
    const el = renderDiff(result)
    const text = el.textContent?.toLowerCase() ?? ''
    expect(text).not.toMatch(/truncated/i)
  })

  it('renders <script> in diff content as text not HTML (SEC-H4)', () => {
    const result = makeResult({
      files: [makeFile({
        hunks: [makeHunk('@@ @@', [makeLine('added', '<script>evil()</script>')])],
      })],
    })
    const el = renderDiff(result)
    expect(el.textContent).toContain('<script>evil()</script>')
    expect(el.querySelectorAll('script').length).toBe(0)
  })

  it('returns an HTMLElement', () => {
    expect(renderDiff(makeResult())).toBeInstanceOf(HTMLElement)
  })
})

// ── mountDiffViewer ───────────────────────────────────────────────────────────

describe('mountDiffViewer', () => {
  function makeContainer(): HTMLDivElement {
    return document.createElement('div')
  }

  it('renders into the container on mount', async () => {
    const result = makeResult()
    vi.stubGlobal('fetch', vi.fn(async () => ({
      ok: true,
      json: async () => result,
    })))

    const container = makeContainer()
    const handle = mountDiffViewer(container, '/repo', {})
    await new Promise((r) => setTimeout(r, 0)) // let async fetch settle
    expect(container.children.length).toBeGreaterThan(0)
    handle.destroy()
  })

  it('shows working tree by default (staged=false)', async () => {
    const mockFetch = vi.fn(async () => ({
      ok: true,
      json: async () => makeResult({ staged: false }),
    }))
    vi.stubGlobal('fetch', mockFetch)

    const container = makeContainer()
    const handle = mountDiffViewer(container, '/repo', {})
    await new Promise((r) => setTimeout(r, 0))

    const url = mockFetch.mock.calls[0]?.[0] as string
    expect(url).toContain('staged=false')
    handle.destroy()
  })

  it('can switch to staged view', async () => {
    const mockFetch = vi.fn(async () => ({
      ok: true,
      json: async () => makeResult({ staged: true }),
    }))
    vi.stubGlobal('fetch', mockFetch)

    const container = makeContainer()
    const handle = mountDiffViewer(container, '/repo', {})
    await new Promise((r) => setTimeout(r, 0))

    // Switch to staged
    handle.showStaged()
    await new Promise((r) => setTimeout(r, 0))

    // Should have fetched twice: once working, once staged
    expect(mockFetch).toHaveBeenCalledTimes(2)
    const secondUrl = mockFetch.mock.calls[1]?.[0] as string
    expect(secondUrl).toContain('staged=true')
    handle.destroy()
  })

  it('can switch back to working view', async () => {
    const mockFetch = vi.fn(async () => ({
      ok: true,
      json: async () => makeResult(),
    }))
    vi.stubGlobal('fetch', mockFetch)

    const container = makeContainer()
    const handle = mountDiffViewer(container, '/repo', {})
    await new Promise((r) => setTimeout(r, 0))
    handle.showStaged()
    await new Promise((r) => setTimeout(r, 0))
    handle.showWorking()
    await new Promise((r) => setTimeout(r, 0))

    expect(mockFetch).toHaveBeenCalledTimes(3)
    const thirdUrl = mockFetch.mock.calls[2]?.[0] as string
    expect(thirdUrl).toContain('staged=false')
    handle.destroy()
  })

  it('calls onClose when the close action fires', async () => {
    vi.stubGlobal('fetch', vi.fn(async () => ({
      ok: true,
      json: async () => makeResult(),
    })))

    const onClose = vi.fn()
    const container = makeContainer()
    const handle = mountDiffViewer(container, '/repo', { onClose })
    await new Promise((r) => setTimeout(r, 0))

    handle.close()
    expect(onClose).toHaveBeenCalledOnce()
    handle.destroy()
  })

  it('shows error/empty state when fetch fails (does not throw)', async () => {
    vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))

    const container = makeContainer()
    const handle = mountDiffViewer(container, '/repo', {})
    await new Promise((r) => setTimeout(r, 0))

    // Should not throw and should render something
    expect(container.children.length).toBeGreaterThan(0)
    handle.destroy()
  })

  it('destroy removes content from container', async () => {
    vi.stubGlobal('fetch', vi.fn(async () => ({
      ok: true,
      json: async () => makeResult(),
    })))

    const container = makeContainer()
    const handle = mountDiffViewer(container, '/repo', {})
    await new Promise((r) => setTimeout(r, 0))
    handle.destroy()
    expect(container.children.length).toBe(0)
  })

  it('handles empty diff gracefully (no throw)', async () => {
    vi.stubGlobal('fetch', vi.fn(async () => ({
      ok: true,
      json: async () => makeResult({ files: [] }),
    })))

    const container = makeContainer()
    const handle = mountDiffViewer(container, '/some/repo', {})
    await expect(new Promise((r) => setTimeout(r, 0))).resolves.toBeUndefined()
    handle.destroy()
  })
})
