import Ajv, { type ValidateFunction } from 'ajv';
import { describe, expect, it } from 'vitest';
import vegaLiteSchema from 'vega-lite/vega-lite-schema.json';
import { PARAMS, type ParamField, paramPlacementAt, paramSiteAt } from './spec-params';
import { snippetToPlain } from './spec-snippet';

/** Compile the bundled Vega-Lite schema once so every param can be validated for real. */
const validateSpec: ValidateFunction = new Ajv({
  strict: false,
  validateFormats: false,
}).compile(vegaLiteSchema as object);

/** Place a `|` marker, strip it, and resolve the param site at that offset. */
function siteAt(marked: string) {
  const offset = marked.indexOf('|');
  return paramSiteAt(marked.replace('|', ''), offset);
}

/** Place a `|` marker, strip it, and classify the param slot at that offset. */
function placeAt(marked: string) {
  const offset = marked.indexOf('|');
  return paramPlacementAt(marked.replace('|', ''), offset);
}

const FIELDS: readonly ParamField[] = [
  { name: 'region', type: 'string', extent: null },
  { name: 'price', type: 'number', extent: { min: 12, max: 988 } },
  { name: 'date', type: 'date', extent: null },
];

/** The shape of a parsed parameter entry — enough to assert on without `any`. */
interface ParsedParam {
  name: string;
  value?: unknown;
  bind?: { input: string; min?: number; max?: number; step?: number; options?: unknown[] };
  select?: { type: string; fields?: string[]; encodings?: string[] };
}

/** Strip a param snippet to its defaults and parse it as a plain (typed) entry. */
function plain(snippet: string): ParsedParam {
  return JSON.parse(snippetToPlain(snippet)) as ParsedParam;
}

describe('parameter catalog', () => {
  it('every parameter, seeded, is valid Vega-Lite', () => {
    const data = { values: [{ region: 'A', price: 1 }] };
    for (const p of PARAMS) {
      const ok = validateSpec({
        data,
        mark: 'point',
        encoding: {},
        params: [plain(p.param(FIELDS))],
      });
      expect(ok, `${p.id}: ${JSON.stringify(validateSpec.errors?.[0])}`).toBe(true);
    }
  });

  it('variable parameters carry a bind, selections carry a select', () => {
    for (const p of PARAMS) {
      const entry = plain(p.param(FIELDS));
      if (p.family === 'variable') {
        expect(entry.bind, p.id).toBeDefined();
        expect(entry.select, p.id).toBeUndefined();
      } else {
        expect(entry.select, p.id).toBeDefined();
        expect(entry.bind, p.id).toBeUndefined();
      }
    }
  });

  it('a slider is seeded from the numeric column extent', () => {
    const slider = PARAMS.find((p) => p.id === 'slider')!;
    const entry = plain(slider.param(FIELDS));
    expect(entry.bind?.min).toBe(12);
    expect(entry.bind?.max).toBe(988);
    // Step ~ 1/100 of the range, snapped to a nice number; value is the midpoint on it.
    expect(entry.bind?.step).toBe(10);
    expect(entry.value).toBe(500);
  });

  it('a slider falls back to 0–100 with no numeric column in scope', () => {
    const slider = PARAMS.find((p) => p.id === 'slider')!;
    expect(plain(slider.param([])).bind).toMatchObject({
      min: 0,
      max: 100,
      step: 1,
      input: 'range',
    });
  });

  it('a point selection is seeded from a categorical column', () => {
    const point = PARAMS.find((p) => p.id === 'point')!;
    expect(plain(point.param(FIELDS)).select?.fields).toEqual(['region']);
    expect(plain(point.param([])).select?.fields).toEqual(['category']);
  });
});

describe('paramSiteAt', () => {
  it('returns null for a non-object root', () => {
    expect(paramSiteAt('[1, 2]', 1)).toBeNull();
    expect(paramSiteAt('', 0)).toBeNull();
  });

  it('a single-view spec shares one host for both families', () => {
    const site = siteAt('{ "mark": "ba|r", "encoding": {} }');
    expect(site).not.toBeNull();
    expect(site!.unit).not.toBeNull();
    // Root is the unit, so both families anchor to the same object.
    expect(site!.unit!.view.offset).toBe(site!.root.view.offset);
    expect(site!.root.array).toBeNull(); // no params yet
  });

  it('reports an existing params array with its element count', () => {
    const site = siteAt('{ "mark": "point", "params": [{ "name": "x" }], "enc|oding": {} }');
    expect(site!.root.array?.count).toBe(1);
    expect(site!.unit!.array?.count).toBe(1);
  });

  it('a composed spec anchors selection to the enclosing unit, variable to the root', () => {
    const spec = `{
  "hconcat": [
    { "mark": "ba|r", "encoding": {} },
    { "mark": "line", "encoding": {} }
  ]
}`;
    const site = siteAt(spec);
    expect(site!.root.view.offset).toBe(0); // the hconcat root
    expect(site!.unit).not.toBeNull();
    // The unit is the first child, a different object than the root.
    expect(site!.unit!.view.offset).not.toBe(site!.root.view.offset);
  });

  it('has no unit when the cursor is in the container but no leaf view', () => {
    const spec = `{
  "hconcat": [|
    { "mark": "bar", "encoding": {} }
  ]
}`;
    const site = siteAt(spec);
    expect(site!.root.view.offset).toBe(0);
    expect(site!.unit).toBeNull();
  });

  it('a layer container with a shared encoding is not a unit (its params are illegal)', () => {
    // Nested LayerSpec carries `encoding` but the schema gives it no `params`.
    const spec = '{ "vconcat": [{ "layer": [{ "mark": "bar" }], "encoding": { "x|": {} } }] }';
    expect(siteAt(spec)!.unit).toBeNull();
  });

  it('a leaf inside a layer is still a unit', () => {
    const spec = '{ "vconcat": [{ "layer": [{ "mark": "b|ar" }], "encoding": {} }] }';
    const site = siteAt(spec);
    expect(site!.unit).not.toBeNull();
    expect(site!.unit!.view.offset).toBe(spec.replace('|', '').indexOf('{ "mark"'));
  });
});

describe('paramPlacementAt', () => {
  it('classifies a root params slot as atRoot', () => {
    expect(placeAt('{ "mark": "point", "params": [|] }')).toEqual({ kind: 'slot', atRoot: true });
  });

  it('classifies a nested unit params slot as not atRoot', () => {
    const spec = '{ "hconcat": [{ "mark": "bar", "params": [|] }] }';
    expect(placeAt(spec)).toEqual({ kind: 'slot', atRoot: false });
  });

  it('is none on a property key or outside any params array', () => {
    expect(placeAt('{ "par|ams": [] }').kind).toBe('none');
    expect(placeAt('{ "mark": "po|int" }').kind).toBe('none');
  });
});
