import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render } from '@testing-library/react'

// Hoisted mock for html2canvas — tests reach into `mockToBlob` to switch
// between "blob produced" and "toBlob returned null" without re-mocking.
type ToBlobCb = (blob: Blob | null) => void
const mockToBlob = vi.fn((cb: ToBlobCb) => cb(new Blob(['png-bytes'])))

vi.mock('html2canvas', () => ({
  default: () => Promise.resolve({ toBlob: mockToBlob }),
}))

import {
  toCsvString,
  downloadToCSV,
  triggerLinkDownload,
  downloadDOMToPNG,
  sanitizeFilename,
} from './exports'
import { CSVIcon, PNGIcon } from './icons'

let csvText = ''

let revokeSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
  csvText = ''
  mockToBlob.mockReset()
  mockToBlob.mockImplementation((cb: ToBlobCb) => cb(new Blob(['png-bytes'])))
  vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
  revokeSpy = vi
    .spyOn(URL, 'revokeObjectURL')
    .mockImplementation(() => undefined)
  const RealBlob = global.Blob
  vi.stubGlobal(
    'Blob',
    class extends RealBlob {
      constructor(parts: BlobPart[], opts?: BlobPropertyBag) {
        csvText = typeof parts[0] === 'string' ? parts[0] : ''
        super(parts, opts)
      }
    },
  )
})

describe('toCsvString', () => {
  it('joins rows with newlines and cells with commas', () => {
    expect(
      toCsvString([
        ['name', 'value'],
        ['a', 1],
        ['b', 2],
      ]),
    ).toBe('name,value\na,1\nb,2')
  })

  it('emits blank cells for null and undefined', () => {
    expect(toCsvString([[null, undefined, 'x']])).toBe(',,x')
  })

  it('stringifies numbers and booleans', () => {
    expect(toCsvString([[1, false, true]])).toBe('1,false,true')
  })

  it('JSON.stringifies objects and arrays', () => {
    expect(toCsvString([[{ a: 1 }, [1, 2]]])).toBe('"{""a"":1}","[1,2]"')
  })

  it('quotes cells containing comma / quote / newline', () => {
    expect(toCsvString([['a,b', 'he said "hi"', 'line1\nline2']])).toBe(
      '"a,b","he said ""hi""","line1\nline2"',
    )
  })

  describe('formula injection guard', () => {
    it.each([
      [
        '=',
        '=HYPERLINK("https://attacker.example")',
        '\'=HYPERLINK("https://attacker.example")',
      ],
      ['+', '+1+1', "'+1+1"],
      ['-', '-2+1', "'-2+1"],
      ['@', '@SUM(A1:A2)', "'@SUM(A1:A2)"],
      ['\\t', '\tleading-tab', "'\tleading-tab"],
      ['\\r', '\rleading-cr', "'\rleading-cr"],
    ])(
      'prefixes cells starting with %s with a single quote',
      (_label, raw, expectedRaw) => {
        // The escape step also quotes if the value contains comma/quote/newline,
        // so we re-derive the exact wire representation from the raw expected.
        const expected = /[",\n\r]/.test(expectedRaw)
          ? `"${expectedRaw.replace(/"/g, '""')}"`
          : expectedRaw
        expect(toCsvString([[raw]])).toBe(expected)
      },
    )

    it('does not touch values where the dangerous char is not leading', () => {
      expect(toCsvString([['a=b']])).toBe('a=b')
      expect(toCsvString([['1+2']])).toBe('1+2')
    })
  })
})

describe('downloadToCSV', () => {
  it('serialises rows then wraps them in a URL handle with a working revoke', () => {
    const handle = downloadToCSV([['x', 1]])
    expect(handle.url).toBe('blob:mock')
    expect(csvText).toBe('x,1')
    handle.revoke?.()
    expect(revokeSpy).toHaveBeenCalledWith('blob:mock')
  })
})

describe('triggerLinkDownload', () => {
  it('synthesises an anchor, clicks it, then removes it', () => {
    const appendSpy = vi.spyOn(document.body, 'appendChild')
    triggerLinkDownload({ url: 'blob:x', filename: 'demo.csv' })
    expect(appendSpy).toHaveBeenCalled()
    const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
    expect(anchor.href).toBe('blob:x')
    expect(anchor.download).toBe('demo.csv')
    expect(anchor.isConnected).toBe(false)
  })

  it('applies sanitizeFilename to the anchor download attribute', () => {
    const appendSpy = vi.spyOn(document.body, 'appendChild')
    const before = appendSpy.mock.calls.length
    triggerLinkDownload({
      url: 'blob:y',
      filename: '..\\evil\r\nname.csv',
    })
    const anchor = appendSpy.mock.calls[before]?.[0] as HTMLAnchorElement
    expect(anchor.download).toBe('.._evil__name.csv')
  })
})

describe('sanitizeFilename', () => {
  it('replaces path separators, NUL, and ASCII control chars with underscore', () => {
    expect(sanitizeFilename('a/b\\c\x00\x07d\r\ne.csv')).toBe('a_b_c__d__e.csv')
  })

  it('clamps long filenames to 200 chars', () => {
    const long = 'x'.repeat(500) + '.csv'
    const out = sanitizeFilename(long)
    expect(out.length).toBe(200)
    expect(out.startsWith('x')).toBe(true)
  })

  it('falls back to "download" for empty or whitespace-only input', () => {
    expect(sanitizeFilename('')).toBe('download')
    // Whitespace stays — sanitizer only handles control bytes — but a leading
    // NUL run that empties the string still falls back.
    expect(sanitizeFilename('\x00\x00\x00')).toBe('___')
  })
})

describe('downloadDOMToPNG', () => {
  it('rasterises the element via html2canvas and resolves with a handle', async () => {
    const handle = await downloadDOMToPNG({
      element: document.createElement('div'),
    })
    expect(handle.url).toBe('blob:mock')
    handle.revoke?.()
    expect(revokeSpy).toHaveBeenCalledWith('blob:mock')
  })

  it('rejects when canvas.toBlob returns null', async () => {
    mockToBlob.mockImplementation((cb: ToBlobCb) => cb(null))
    await expect(
      downloadDOMToPNG({ element: document.createElement('div') }),
    ).rejects.toThrow(/toBlob/)
  })

  it('forwards pixelRatio and backgroundColor overrides', async () => {
    // We can't easily inspect the call args without re-hoisting the mock,
    // so just guard the happy path still works with overrides supplied.
    const handle = await downloadDOMToPNG({
      element: document.createElement('div'),
      pixelRatio: 4,
      backgroundColor: '#fff',
    })
    expect(handle.url).toBe('blob:mock')
  })
})

describe('download icons', () => {
  it('CSVIcon renders an SVG element', () => {
    const { container } = render(<CSVIcon fontSize='small' />)
    expect(container.querySelector('svg')).not.toBeNull()
  })

  it('PNGIcon renders an SVG element', () => {
    const { container } = render(<PNGIcon fontSize='small' />)
    expect(container.querySelector('svg')).not.toBeNull()
  })
})
