import Ajv, { type ValidateFunction } from 'ajv';
import { describe, expect, it } from 'vitest';
import vegaLiteSchema from 'vega-lite/vega-lite-schema.json';
import {
  DATA_TRANSFORMS,
  transformPlacementAt,
  transformSiteAt,
  type TypedField,
} from './spec-data-transforms';
import { snippetToPlain } from './spec-snippet';

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

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

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

const FIELDS: TypedField[] = [
  { name: 'price', type: 'number' },
  { name: 'date', type: 'date' },
  { name: 'origin', type: 'string' },
];

describe('transformPlacementAt — step slots', () => {
  it('classifies an empty transform array as a step slot', () => {
    expect(placeAt('{"transform":[|],"mark":"bar"}')).toEqual({ kind: 'step', scope: 'view' });
  });

  it('classifies the slot after an existing step', () => {
    expect(placeAt('{"transform":[{"filter":"x"},|],"mark":"bar"}')).toEqual({
      kind: 'step',
      scope: 'view',
    });
  });

  it('classifies a bare partial word in the array as a step slot', () => {
    // `fil` is not valid JSON, but jsonc-parser locates the slot regardless.
    expect(placeAt('{"transform":[fil|],"mark":"bar"}')).toEqual({ kind: 'step', scope: 'view' });
  });

  it('marks a transform on a composition parent as shared (upstream of children)', () => {
    const src = '{"transform":[|],"layer":[{"mark":"bar"},{"mark":"line"}]}';
    expect(placeAt(src)).toEqual({ kind: 'step', scope: 'shared' });
  });

  it('resolves the owning view of a nested transform (layer child = local)', () => {
    const src = '{"layer":[{"transform":[|],"mark":"bar"},{"mark":"line"}]}';
    expect(placeAt(src)).toEqual({ kind: 'step', scope: 'view' });
  });
});

describe('transformPlacementAt — not a step slot', () => {
  it('stays out of a key inside an existing step object (the schema owns that)', () => {
    expect(placeAt('{"transform":[{"fil|"}],"mark":"bar"}')).toEqual({ kind: 'none' });
  });

  it('does not fire at an encoding channel key (the schema completes those)', () => {
    expect(placeAt('{"encoding":{"x":{"field":"date","ti|"}}}')).toEqual({ kind: 'none' });
  });

  it('does not fire at a bare view key', () => {
    expect(placeAt('{"mark":"bar","tr|"}')).toEqual({ kind: 'none' });
  });

  it('returns none for a plain value position', () => {
    expect(placeAt('{"mark":"|"}')).toEqual({ kind: 'none' });
  });
});

describe('transformSiteAt', () => {
  it('reports no array on a bare unit view (cursor anywhere inside it)', () => {
    const site = siteAt('{"mark":"bar","encoding":{"x":{"field":"a|"}}}');
    expect(site).toMatchObject({ scope: 'view', array: null });
    expect(site?.view.offset).toBe(0);
  });

  it('reports the array and its element count when a pipeline exists', () => {
    const site = siteAt('{"transform":[{"filter":"x"}],"mark":"|bar"}');
    expect(site?.array).toMatchObject({ count: 1 });
  });

  it('reports an empty array with count 0', () => {
    const site = siteAt('{"transform":[|],"mark":"bar"}');
    expect(site?.array).toMatchObject({ count: 0 });
  });

  it('resolves the site from inside the transform array itself', () => {
    const site = siteAt('{"transform":[{"filter":"x"},|],"mark":"bar"}');
    expect(site?.array).toMatchObject({ count: 1 });
    expect(site?.view.offset).toBe(0);
  });

  it('climbs to the nearest view — a layer child is local (view scope)', () => {
    const site = siteAt('{"layer":[{"mark":"ba|r"},{"mark":"line"}]}');
    expect(site).toMatchObject({ scope: 'view', array: null });
    expect(site?.view.offset).toBeGreaterThan(0); // the child, not the root
  });

  it('marks a composition parent’s pipeline as shared', () => {
    const site = siteAt('{"transform":[|],"layer":[{"mark":"bar"}]}');
    expect(site).toMatchObject({ scope: 'shared' });
  });

  it('the view range spans the whole enclosing view object', () => {
    const src = '{"mark":"bar","encodi|ng":{}}';
    const site = siteAt(src);
    // offset at the opening brace, length to the closing brace inclusive.
    expect(site?.view.offset).toBe(0);
    expect(site?.view.length).toBe(src.replace('|', '').length);
  });
});

describe('catalog — every step snippet, seeded, is valid Vega-Lite', () => {
  /** A unit spec carrying the stripped step, for real-schema validation. */
  const withStep = (snippet: string) => ({
    data: { values: [{ price: 1, origin: 'A' }] },
    mark: 'point',
    encoding: {},
    transform: [JSON.parse(snippetToPlain(snippet)) as unknown],
  });
  for (const t of DATA_TRANSFORMS) {
    it(`${t.id} (with fields in scope)`, () => {
      const ok = validateSpec(withStep(t.step(FIELDS)));
      expect(ok, JSON.stringify(validateSpec.errors?.[0])).toBe(true);
    });
    it(`${t.id} (no data in scope — generic fallbacks)`, () => {
      const ok = validateSpec(withStep(t.step([])));
      expect(ok, JSON.stringify(validateSpec.errors?.[0])).toBe(true);
    });
  }
});

describe('catalog — field-typed defaults', () => {
  it('seeds filter with the first available column', () => {
    expect(JSON.parse(snippetToPlain(byId('filter').step(FIELDS)))).toEqual({
      filter: 'datum.price > 0',
    });
  });

  it('seeds aggregate with a numeric field and a categorical groupby', () => {
    const parsed = parseAggregate(byId('aggregate').step(FIELDS));
    expect(parsed.aggregate[0]).toMatchObject({ field: 'price', as: 'price_mean' });
    expect(parsed.groupby).toEqual(['origin']);
  });

  it('seeds timeUnit with a temporal field', () => {
    const parsed = JSON.parse(snippetToPlain(byId('timeUnit').step(FIELDS))) as { field: string };
    expect(parsed.field).toBe('date');
  });

  it('falls back to generic names when no column matches the need', () => {
    const parsed = parseAggregate(byId('aggregate').step([]));
    expect(parsed.aggregate[0].field).toBe('value');
    expect(parsed.groupby).toEqual(['category']);
  });
});

function byId(id: string) {
  const t = DATA_TRANSFORMS.find((x) => x.id === id);
  if (!t) throw new Error(`no transform ${id}`);
  return t;
}

/** Parse an aggregate step snippet into its (typed) fields, for assertions. */
function parseAggregate(snippet: string): {
  aggregate: Array<{ op: string; field: string; as: string }>;
  groupby: string[];
} {
  return JSON.parse(snippetToPlain(snippet)) as {
    aggregate: Array<{ op: string; field: string; as: string }>;
    groupby: string[];
  };
}
