import { describe, expect, it } from 'vitest';
import { injectDatasets, parseLesson, type LessonBlock } from './lesson-parse';

const LESSON = `---
slug: binning
title: From bars to a real histogram
tagline: One word turns a noisy bar chart into a distribution.
---

Counting a continuous field is *almost* right.

:::progression
## raw counts
A bar per distinct value — a picket fence.

\`\`\`vega-lite
{ "mark": "bar", "encoding": { "x": { "field": "v" } } }
\`\`\`

## + bin
\`bin: true\` is the whole fix.

\`\`\`vega-lite
{ "mark": "bar", "encoding": { "x": { "field": "v", "bin": true } } }
\`\`\`
:::

Then a standalone chart:

\`\`\`vega-lite
{ "mark": "point" }
\`\`\`

:::sharp-edge
Tempted to wire a slider to bin width? It won't work.
:::
`;

const types = (blocks: LessonBlock[]) => blocks.map((b) => b.type);

describe('parseLesson — block model', () => {
  it('reads frontmatter as lesson metadata', () => {
    const lesson = parseLesson(LESSON);
    expect(lesson.slug).toBe('binning');
    expect(lesson.title).toBe('From bars to a real histogram');
    expect(lesson.tagline).toBe('One word turns a noisy bar chart into a distribution.');
  });

  it('emits blocks in document order', () => {
    expect(types(parseLesson(LESSON).blocks)).toEqual([
      'prose',
      'progression',
      'prose',
      'chart',
      'callout',
    ]);
  });

  it('parses progression stages: label, note, parsed spec', () => {
    const block = parseLesson(LESSON).blocks[1];
    if (block.type !== 'progression') throw new Error('expected progression');
    const [raw, binned] = block.stages;
    expect(raw.label).toBe('raw counts');
    expect(raw.note).toBe('A bar per distinct value — a picket fence.');
    expect(raw.spec).toEqual({ mark: 'bar', encoding: { x: { field: 'v' } } });
    expect((binned.spec.encoding as { x: { bin?: unknown } }).x.bin).toBe(true);
  });

  it('parses a standalone chart block and a callout variant', () => {
    const { blocks } = parseLesson(LESSON);
    const chart = blocks[3];
    const callout = blocks[4];
    expect(chart.type === 'chart' && chart.spec).toEqual({ mark: 'point' });
    expect(callout.type === 'callout' && callout.variant).toBe('sharp-edge');
    expect(callout.type === 'callout' && callout.markdown).toMatch(/slider to bin width/);
  });

  it('handles a prose-only lesson', () => {
    const proseOnly = `---\nslug: x\ntitle: t\ntagline: g\n---\nJust words.`;
    expect(types(parseLesson(proseOnly).blocks)).toEqual(['prose']);
  });

  it('supports multiple progressions in one lesson', () => {
    const two = `---\nslug: x\ntitle: t\ntagline: g\n---
:::progression
## a
\`\`\`vega-lite
{ "mark": "bar" }
\`\`\`
:::
:::progression
## b
\`\`\`vega-lite
{ "mark": "line" }
\`\`\`
:::`;
    expect(types(parseLesson(two).blocks)).toEqual(['progression', 'progression']);
  });

  it('throws on missing frontmatter', () => {
    expect(() => parseLesson('## a')).toThrow(/frontmatter/);
  });

  it('throws on a missing required field', () => {
    expect(() => parseLesson(`---\nslug: x\ntagline: y\n---\nhi`)).toThrow(/missing "title"/);
  });

  it('throws on a slug outside [a-z0-9-] (router/shell contract)', () => {
    expect(() => parseLesson(`---\nslug: Bad_Slug\ntitle: t\ntagline: g\n---\nhi`)).toThrow(
      /slug must be lowercase/,
    );
  });

  it('throws with stage context on invalid JSON', () => {
    const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::progression\n## a\n\`\`\`vega-lite\n{ bad }\n\`\`\`\n:::`;
    expect(() => parseLesson(bad)).toThrow(/stage "a": invalid JSON/);
  });

  it('throws on an invalid standalone chart spec', () => {
    const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n\`\`\`vega-lite\n[1,2]\n\`\`\``;
    expect(() => parseLesson(bad)).toThrow(/chart: spec must be a JSON object/);
  });

  it('normalizes CRLF line endings', () => {
    expect(types(parseLesson(LESSON.replace(/\n/g, '\r\n')).blocks)).toEqual([
      'prose',
      'progression',
      'prose',
      'chart',
      'callout',
    ]);
  });

  it('defaults to no datasets when there is no :::data block', () => {
    expect(parseLesson(LESSON).datasets).toEqual({});
  });
});

describe('parseLesson — :::data blocks', () => {
  const withData = `---\nslug: x\ntitle: t\ntagline: g\n---
:::data
{ "ev": [{ "arm": "A", "n": 1 }, { "arm": "B", "n": 2 }] }
:::

A chart that references it by name:

\`\`\`vega-lite
{ "data": { "name": "ev" }, "mark": "bar" }
\`\`\``;

  it('collects named rows into lesson.datasets, not as a block', () => {
    const lesson = parseLesson(withData);
    expect(lesson.datasets).toEqual({
      ev: [
        { arm: 'A', n: 1 },
        { arm: 'B', n: 2 },
      ],
    });
    // The data directive is metadata — only the prose + chart are blocks.
    expect(types(lesson.blocks)).toEqual(['prose', 'chart']);
  });

  it('merges multiple :::data blocks', () => {
    const two = `---\nslug: x\ntitle: t\ntagline: g\n---
:::data
{ "a": [{ "v": 1 }] }
:::
:::data
{ "b": [{ "v": 2 }] }
:::
Words.`;
    expect(parseLesson(two).datasets).toEqual({ a: [{ v: 1 }], b: [{ v: 2 }] });
  });

  it('throws on a non-object :::data body', () => {
    const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::data\n[1, 2]\n:::`;
    expect(() => parseLesson(bad)).toThrow(/:::data: body must be a JSON object/);
  });

  it('throws when a dataset value is not an array', () => {
    const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::data\n{ "ev": 5 }\n:::\nhi`;
    expect(() => parseLesson(bad)).toThrow(/:::data "ev": value must be an array/);
  });
});

describe('injectDatasets', () => {
  const datasets = { ev: [{ n: 1 }] };

  it('merges lesson datasets into a clone, leaving the source spec untouched', () => {
    const spec = { data: { name: 'ev' }, mark: 'bar' };
    const out = injectDatasets(spec, datasets);
    expect(out).toEqual({ data: { name: 'ev' }, mark: 'bar', datasets: { ev: [{ n: 1 }] } });
    expect(spec).not.toHaveProperty('datasets');
  });

  it("lets a spec's own datasets win on a name clash", () => {
    const spec = { datasets: { ev: [{ n: 99 }] }, mark: 'bar' };
    expect(injectDatasets(spec, datasets).datasets).toEqual({ ev: [{ n: 99 }] });
  });

  it('returns the spec unchanged when there are no datasets', () => {
    const spec = { mark: 'bar' };
    expect(injectDatasets(spec, {})).toBe(spec);
  });
});
