import { describe, expect, it } from 'vitest';
import {
  chartExportFilename,
  embedFontsInSvg,
  inlineReferencedDatasets,
  MAX_BASENAME_LEN,
  referencedDatasetNames,
  referencedUploadedFonts,
  snippetFileBasename,
} from './chart-export';
import { createFontAsset, fontFamilyStack, type FontAsset } from './font-asset';
import { DatasetNotFoundError, type ResolvableDataset } from './rendering';

describe('snippetFileBasename', () => {
  it('keeps the user’s words and case', () => {
    expect(snippetFileBasename('Sales by Region')).toBe('Sales-by-Region');
  });

  it('swaps whitespace runs for a single dash', () => {
    expect(snippetFileBasename('  a   b  c  ')).toBe('a-b-c');
  });

  it('folds dots into the separator so the extension stays unambiguous', () => {
    expect(snippetFileBasename('data.v1.final')).toBe('data-v1-final');
  });

  it('strips characters illegal on Windows / awkward in URLs', () => {
    expect(snippetFileBasename('a/b\\c:d*e?f"g<h>i|j')).toBe('abcdefghij');
  });

  it('keeps non-latin letters (no ASCII folding — full-script support)', () => {
    expect(snippetFileBasename('Продажи по региону')).toBe('Продажи-по-региону');
    expect(snippetFileBasename('売上 グラフ')).toBe('売上-グラフ');
  });

  it('collapses and trims dashes', () => {
    expect(snippetFileBasename('--a -- b--')).toBe('a-b');
  });

  it('falls back to "chart" when nothing usable remains', () => {
    expect(snippetFileBasename('')).toBe('chart');
    expect(snippetFileBasename('   ')).toBe('chart');
    expect(snippetFileBasename('/// \\\\\\')).toBe('chart');
    expect(snippetFileBasename('...')).toBe('chart');
  });

  it('caps the length and never leaves a trailing dash from the cut', () => {
    const long = snippetFileBasename('x'.repeat(200));
    expect(long.length).toBe(MAX_BASENAME_LEN);
    // A name whose cap boundary lands on a dash must not end in one.
    const dashy = snippetFileBasename('a'.repeat(MAX_BASENAME_LEN - 1) + ' bbbb');
    expect(dashy.endsWith('-')).toBe(false);
  });

  it('drops control characters', () => {
    // Build the control byte from a code point so no literal control char is in source.
    expect(snippetFileBasename(`a${String.fromCharCode(1)}bcd`)).toBe('abcd');
  });
});

describe('chartExportFilename', () => {
  it('appends the format as the extension', () => {
    expect(chartExportFilename('Sales by Region', 'png')).toBe('Sales-by-Region.png');
    expect(chartExportFilename('Sales by Region', 'svg')).toBe('Sales-by-Region.svg');
    expect(chartExportFilename('Sales by Region', 'vl.json')).toBe('Sales-by-Region.vl.json');
  });

  it('uses the "chart" fallback for an unusable name', () => {
    expect(chartExportFilename('', 'png')).toBe('chart.png');
  });
});

describe('referencedDatasetNames', () => {
  it('returns the saved-dataset names a spec references, deduped', () => {
    const spec = JSON.stringify({
      layer: [
        { data: { name: 'sales' } },
        { data: { name: 'sales' } },
        { data: { name: 'costs' } },
      ],
    });
    expect(referencedDatasetNames(spec).sort()).toEqual(['costs', 'sales']);
  });

  it('excludes names the spec defines for itself via top-level datasets', () => {
    const spec = JSON.stringify({
      datasets: { local: [{ a: 1 }] },
      data: { name: 'local' },
    });
    expect(referencedDatasetNames(spec)).toEqual([]);
  });

  it('returns [] for an inline-data spec (no references)', () => {
    const spec = JSON.stringify({ data: { values: [{ a: 1 }] }, mark: 'bar' });
    expect(referencedDatasetNames(spec)).toEqual([]);
  });

  it('returns [] for unparseable text rather than throwing', () => {
    expect(referencedDatasetNames('{ not json')).toEqual([]);
  });
});

describe('inlineReferencedDatasets', () => {
  const sales: ResolvableDataset = {
    name: 'sales',
    data: [{ region: 'N', value: 10 }],
    format: 'json',
    source: 'inline',
  };

  const parse = (text: string) => JSON.parse(text) as { data?: unknown; width?: unknown };

  it('replaces a named reference with the dataset’s inline values', () => {
    const spec = JSON.stringify({ data: { name: 'sales' }, mark: 'bar' });
    const out = parse(inlineReferencedDatasets(spec, [sales]));
    expect(out.data).toEqual({ values: [{ region: 'N', value: 10 }] });
  });

  it('resolves the name case-insensitively (mirrors reference resolution)', () => {
    const spec = JSON.stringify({ data: { name: 'Sales' }, mark: 'bar' });
    const out = parse(inlineReferencedDatasets(spec, [sales]));
    expect(out.data).toEqual({ values: [{ region: 'N', value: 10 }] });
  });

  it('leaves sizing as authored — no fit-mode applied', () => {
    const spec = JSON.stringify({ data: { name: 'sales' }, width: 300, mark: 'bar' });
    const out = parse(inlineReferencedDatasets(spec, [sales]));
    expect(out.width).toBe(300);
  });

  it('throws DatasetNotFoundError when a referenced dataset is missing', () => {
    const spec = JSON.stringify({ data: { name: 'missing' }, mark: 'bar' });
    expect(() => inlineReferencedDatasets(spec, [sales])).toThrow(DatasetNotFoundError);
  });
});

describe('referencedUploadedFonts', () => {
  const font = (family: string): FontAsset =>
    createFontAsset({
      family,
      data: new Uint8Array([1, 2, 3, 4]).buffer,
      format: 'woff2',
      fileName: `${family}.woff2`,
    });
  const brand = font('Brand');
  const display = font('Display Face');

  it('matches an uploaded face named in the spec (by its stack’s primary family)', () => {
    const spec = JSON.stringify({ config: { font: fontFamilyStack('Brand') }, mark: 'bar' });
    expect(referencedUploadedFonts(spec, undefined, [brand, display])).toEqual([brand]);
  });

  it('matches a face named only in the chart config (theme-only font)', () => {
    const config = { axis: { labelFont: fontFamilyStack('Display Face') } };
    expect(referencedUploadedFonts('{}', config, [brand, display])).toEqual([display]);
  });

  it('matches case-insensitively', () => {
    const config = { font: '"brand", sans-serif' };
    expect(referencedUploadedFonts('{}', config, [brand])).toEqual([brand]);
  });

  it('ignores roster/system stacks that match no uploaded face', () => {
    const config = { font: '"Inter", system-ui, sans-serif' };
    expect(referencedUploadedFonts('{}', config, [brand])).toEqual([]);
  });

  it('returns [] for an empty library or an unparseable spec with no config font', () => {
    expect(referencedUploadedFonts('{}', undefined, [])).toEqual([]);
    expect(referencedUploadedFonts('not json', undefined, [brand])).toEqual([]);
  });
});

describe('embedFontsInSvg', () => {
  const font = (family: string): FontAsset =>
    createFontAsset({
      family,
      data: new Uint8Array([1, 2, 3, 4]).buffer,
      format: 'woff2',
      fileName: `${family}.woff2`,
    });
  const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="80"><g/></svg>';

  it('returns the SVG unchanged when there are no fonts', () => {
    expect(embedFontsInSvg(svg, [])).toBe(svg);
  });

  it('injects an @font-face style with a base64 data-URI src as the first child', () => {
    const out = embedFontsInSvg(svg, [font('Brand')]);
    expect(out).toContain('<style');
    expect(out).toContain('@font-face');
    expect(out).toContain('font-family:"Brand"');
    expect(out).toContain('src:url(data:font/woff2;base64,');
    expect(out).toContain('format("woff2")'); // CSS keyword, not the extension
    // The style sits immediately after the opening <svg ...> tag.
    expect(out).toMatch(/<svg\b[^>]*><style/);
  });

  it('uses the CSS format keyword for ttf/otf (truetype/opentype)', () => {
    const ttf = createFontAsset({
      family: 'Mono',
      data: new Uint8Array([1]).buffer,
      format: 'ttf',
      fileName: 'mono.ttf',
    });
    expect(embedFontsInSvg(svg, [ttf])).toContain('format("truetype")');
  });

  it('emits a font-weight range for a variable face so weights resolve', () => {
    const variable = createFontAsset({
      family: 'Flex',
      data: new Uint8Array([1]).buffer,
      format: 'ttf',
      fileName: 'flex.ttf',
      axes: [{ tag: 'wght', min: 100, default: 400, max: 900 }],
    });
    expect(embedFontsInSvg(svg, [variable])).toContain('font-weight:100 900');
  });
});
