import { describe, expect, test } from 'vitest';
import {
  cellText,
  computeDatasetProfile,
  CURRENT_DATASET_VERSION,
  createDataset,
  datasetReference,
  parseDelimited,
  snapshotFromText,
  tabularRows,
} from './dataset';

describe('datasetReference', () => {
  test('produces the by-name reference object', () => {
    expect(datasetReference('MyDataset')).toEqual({ data: { name: 'MyDataset' } });
  });
});

describe('parseDelimited', () => {
  test('parses CSV header + rows into objects', () => {
    const rows = parseDelimited('a,b,c\n1,2,3\n4,5,6', 'csv');
    expect(rows).toEqual([
      { a: '1', b: '2', c: '3' },
      { a: '4', b: '5', c: '6' },
    ]);
  });

  test('parses TSV with the tab delimiter', () => {
    const rows = parseDelimited('x\ty\n1\t2', 'tsv');
    expect(rows).toEqual([{ x: '1', y: '2' }]);
  });

  test('drops a trailing newline (no phantom empty row) and skips blank lines', () => {
    expect(parseDelimited('a,b\n1,2\n', 'csv')).toEqual([{ a: '1', b: '2' }]);
    expect(parseDelimited('a,b\n1,2\n\n3,4', 'csv')).toEqual([
      { a: '1', b: '2' },
      { a: '3', b: '4' },
    ]);
  });

  test('strips surrounding quotes from header and cells (RFC-4180)', () => {
    // The quoted-TSV case from real exports: column keys must not keep the quotes.
    const rows = parseDelimited('"email"\t"id"\n"a@b.com"\t1', 'tsv');
    expect(rows).toEqual([{ email: 'a@b.com', id: '1' }]);
  });

  test('quoted fields keep embedded delimiters, newlines, and escaped quotes', () => {
    const rows = parseDelimited('name,note\n"Doe, John","a ""quote""\nspans"', 'csv');
    expect(rows).toEqual([{ name: 'Doe, John', note: 'a "quote"\nspans' }]);
  });

  test('does not trim unquoted whitespace (matches d3-dsv / Vega)', () => {
    expect(parseDelimited('a, b\n1, 2', 'csv')).toEqual([{ a: '1', ' b': ' 2' }]);
  });

  test('ragged rows: missing cells are undefined, extra cells ignored', () => {
    const rows = parseDelimited('a,b,c\n1\n4,5,6,7', 'csv');
    expect(rows).toEqual([
      { a: '1', b: undefined, c: undefined },
      { a: '4', b: '5', c: '6' },
    ]);
  });

  test('header-only or empty input yields no rows', () => {
    expect(parseDelimited('a,b,c', 'csv')).toEqual([]);
    expect(parseDelimited('', 'csv')).toEqual([]);
  });
});

describe('computeDatasetProfile', () => {
  test('JSON array-of-objects is profiled', () => {
    const data = [
      { a: 1, b: 'x' },
      { a: 2, b: 'y' },
    ];
    const profile = computeDatasetProfile(data, 'json');
    expect(profile.rowCount).toBe(2);
    expect(profile.columns).toEqual(['a', 'b']);
    expect(profile.size).toBe(new TextEncoder().encode(JSON.stringify(data)).length);
  });

  test('JSON that is not an array is N/A but sized', () => {
    const profile = computeDatasetProfile({ a: 1 }, 'json');
    expect(profile.rowCount).toBeNull();
    expect(profile.size).toBeGreaterThan(0);
  });

  test('CSV is parsed and profiled; size is the raw text byte length', () => {
    const text = 'a,b\n1,2';
    const profile = computeDatasetProfile(text, 'csv');
    expect(profile.rowCount).toBe(1);
    expect(profile.columns).toEqual(['a', 'b']);
    expect(profile.size).toBe(new TextEncoder().encode(text).length);
  });

  test('TSV is parsed and profiled', () => {
    const profile = computeDatasetProfile('a\tb\n1\t2', 'tsv');
    expect(profile.rowCount).toBe(1);
    expect(profile.columns).toEqual(['a', 'b']);
  });

  test('TopoJSON is N/A (non-tabular) but sized', () => {
    const topo = { type: 'Topology', objects: {} };
    const profile = computeDatasetProfile(topo, 'topojson');
    expect(profile.rowCount).toBeNull();
    expect(profile.columnCount).toBeNull();
    expect(profile.size).toBe(new TextEncoder().encode(JSON.stringify(topo)).length);
  });

  test('a fetched URL snapshot is profiled exactly like inline data', () => {
    // The snapshot model: once fetched, a URL dataset carries its payload in `data`
    // and profiles through the same path as inline (no source distinction here).
    const profile = computeDatasetProfile('a,b\n1,2\n3,4', 'csv');
    expect(profile.rowCount).toBe(2);
    expect(profile.columns).toEqual(['a', 'b']);
  });

  test('an unfetched URL reference (null data) is N/A with size 0', () => {
    const profile = computeDatasetProfile(null, 'csv');
    expect(profile.rowCount).toBeNull();
    expect(profile.columnCount).toBeNull();
    expect(profile.columns).toEqual([]);
    expect(profile.size).toBe(0);
  });
});

describe('tabularRows', () => {
  test('CSV parses to rows (same parsing as profiling)', () => {
    expect(tabularRows('a,b\n1,2\n3,4', 'csv')).toEqual([
      { a: '1', b: '2' },
      { a: '3', b: '4' },
    ]);
  });

  test('TSV parses to rows', () => {
    expect(tabularRows('x\ty\n1\t2', 'tsv')).toEqual([{ x: '1', y: '2' }]);
  });

  test('a JSON array of objects is returned as-is', () => {
    const data = [{ a: 1 }, { a: 2 }];
    expect(tabularRows(data, 'json')).toEqual(data);
  });

  test('limit returns only the head', () => {
    const rows = tabularRows('n\n1\n2\n3\n4', 'csv', 2);
    expect(rows).toEqual([{ n: '1' }, { n: '2' }]);
  });

  test('non-tabular payloads return null (single object, topojson, unfetched)', () => {
    expect(tabularRows({ a: 1 }, 'json')).toBeNull();
    expect(tabularRows({ type: 'Topology', objects: {} }, 'topojson')).toBeNull();
    expect(tabularRows(null, 'csv')).toBeNull();
    expect(tabularRows('a,b', 'csv')).toBeNull(); // header only — no rows
  });
});

describe('cellText', () => {
  test('strings as-is, null/undefined blank, others as JSON (no "[object Object]")', () => {
    expect(cellText('plain')).toBe('plain');
    expect(cellText(null)).toBe('');
    expect(cellText(undefined)).toBe('');
    expect(cellText(42)).toBe('42');
    expect(cellText(false)).toBe('false');
    expect(cellText({ a: 1 })).toBe('{"a":1}');
  });
});

describe('snapshotFromText', () => {
  test('JSON content is parsed and typed json (content beats extension)', () => {
    const { data, format } = snapshotFromText('[{"a":1}]', 'https://x/data.txt');
    expect(format).toBe('json');
    expect(data).toEqual([{ a: 1 }]);
  });

  test('CSV content keeps its raw text and is typed csv', () => {
    const { data, format } = snapshotFromText('a,b\n1,2', 'https://x/data.csv');
    expect(format).toBe('csv');
    expect(data).toBe('a,b\n1,2');
  });

  test('TSV content is typed tsv', () => {
    expect(snapshotFromText('a\tb\n1\t2', 'https://x/d').format).toBe('tsv');
  });

  test('a Topology body is typed topojson and parsed', () => {
    const { data, format } = snapshotFromText('{"type":"Topology","objects":{}}', 'https://x/d');
    expect(format).toBe('topojson');
    expect(data).toEqual({ type: 'Topology', objects: {} });
  });

  test('unrecognized content falls back to the URL extension', () => {
    // A single token: not JSON, not delimited. The .json extension decides the
    // format; the unparseable body is kept verbatim rather than throwing.
    const { data, format } = snapshotFromText('not-data', 'https://x/data.json');
    expect(format).toBe('json');
    expect(data).toBe('not-data');
  });

  test('unrecognized content with no useful extension defaults to json', () => {
    expect(snapshotFromText('not-data', 'https://x/feed').format).toBe('json');
  });
});

describe('createDataset', () => {
  test('stamps version, equal timestamps, and fills the profile for inline JSON', () => {
    const now = new Date('2026-06-05T10:00:00.000Z');
    const data = [
      { city: 'Kyiv', pop: 2900000 },
      { city: 'Lviv', pop: 720000 },
    ];
    const d = createDataset({
      id: 99,
      name: 'Cities',
      data,
      format: 'json',
      source: 'inline',
      now,
    });

    expect(d.id).toBe(99);
    expect(d.version).toBe(CURRENT_DATASET_VERSION);
    expect(d.name).toBe('Cities');
    expect(d.created).toBe(now.toISOString());
    expect(d.modified).toBe(d.created);
    expect(d.comment).toBe('');
    expect(d.rowCount).toBe(2);
    expect(d.columnCount).toBe(2);
    expect(d.columns).toEqual(['city', 'pop']);
    expect(d.columnTypes).toEqual([
      { name: 'city', type: 'string' },
      { name: 'pop', type: 'number' },
    ]);
    expect(d.size).toBeGreaterThan(0);
  });

  test('fills the profile for inline CSV', () => {
    const d = createDataset({
      id: 1,
      name: 'C',
      data: 'a,b\n1,2\n3,4',
      format: 'csv',
      source: 'inline',
    });
    expect(d.rowCount).toBe(2);
    expect(d.columns).toEqual(['a', 'b']);
  });

  test('URL dataset carries url + fetchedAt and profiles the fetched snapshot', () => {
    const d = createDataset({
      id: 2,
      name: 'Remote',
      data: 'a,b\n1,2',
      url: 'https://example.com/x.csv',
      fetchedAt: '2026-06-10T00:00:00.000Z',
      format: 'csv',
      source: 'url',
      comment: 'remote source',
    });
    expect(d.source).toBe('url');
    expect(d.url).toBe('https://example.com/x.csv');
    expect(d.fetchedAt).toBe('2026-06-10T00:00:00.000Z');
    expect(d.rowCount).toBe(1);
    expect(d.columns).toEqual(['a', 'b']);
    expect(d.comment).toBe('remote source');
  });

  test('an unfetched URL dataset (null data) gets an N/A profile and null fetchedAt', () => {
    const d = createDataset({
      id: 3,
      name: 'Unfetched',
      data: null,
      url: 'https://example.com/x.csv',
      format: 'csv',
      source: 'url',
    });
    expect(d.url).toBe('https://example.com/x.csv');
    expect(d.fetchedAt).toBeNull();
    expect(d.rowCount).toBeNull();
    expect(d.columns).toEqual([]);
    expect(d.size).toBe(0);
  });

  test('inline dataset carries no url/fetchedAt keys', () => {
    const d = createDataset({ id: 4, name: 'I', data: [], format: 'json', source: 'inline' });
    expect('url' in d).toBe(false);
    expect('fetchedAt' in d).toBe(false);
  });

  test('defaults the id when not injected', () => {
    const d = createDataset({ name: 'X', data: [], format: 'json', source: 'inline' });
    expect(typeof d.id).toBe('number');
  });
});
