/**
 * Live-inspection watcher (`watchInspectableData`) — the wiring behind
 * `RenderHandle.onDataChange` (spec §04; multi-view scope doc M5). Verified against
 * a fake Vega view + fake timers; the real selection→filter→data recompute is an
 * integration behavior exercised manually (renderSpec is vega-embed-bound).
 */

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LIVE_INSPECT_DEBOUNCE_MS, watchInspectableData } from './chart-renderer';

/** A compiled-Vega-shape spec: one drawn table data_0, sourced from source_0. */
const vgSpec = {
  data: [{ name: 'source_0' }, { name: 'data_0', source: 'source_0' }],
  marks: [{ type: 'symbol', from: { data: 'data_0' } }],
};

function fakeView() {
  const listeners = new Map<string, Set<() => void>>();
  return {
    added: [] as string[],
    removed: [] as string[],
    addDataListener(name: string, handler: () => void) {
      const set = listeners.get(name) ?? new Set<() => void>();
      set.add(handler);
      listeners.set(name, set);
      this.added.push(name);
    },
    removeDataListener(name: string, handler: () => void) {
      listeners.get(name)?.delete(handler);
      this.removed.push(name);
    },
    fire(name: string) {
      for (const handler of listeners.get(name) ?? []) handler();
    },
  };
}

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

describe('watchInspectableData', () => {
  it('watches both the resolved and input table of each drawn view', () => {
    const view = fakeView();
    watchInspectableData(
      view,
      vgSpec,
      () => {},
      () => false,
    );
    expect(new Set(view.added)).toEqual(new Set(['data_0', 'source_0']));
  });

  it('debounces a burst of changes into a single re-read', () => {
    const view = fakeView();
    const onChange = vi.fn();
    watchInspectableData(view, vgSpec, onChange, () => false);

    view.fire('data_0');
    view.fire('data_0');
    view.fire('data_0'); // a brush drag pulsing
    expect(onChange).not.toHaveBeenCalled(); // still within the debounce window

    vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS);
    expect(onChange).toHaveBeenCalledTimes(1);
  });

  it('unsubscribe detaches listeners and cancels a pending re-read', () => {
    const view = fakeView();
    const onChange = vi.fn();
    const stop = watchInspectableData(view, vgSpec, onChange, () => false);

    view.fire('data_0');
    stop();
    vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS * 2);

    expect(onChange).not.toHaveBeenCalled(); // pending re-read cancelled
    expect(new Set(view.removed)).toEqual(new Set(['data_0', 'source_0']));
  });

  it('is a no-op when the view is already finalized at subscribe', () => {
    const view = fakeView();
    const stop = watchInspectableData(
      view,
      vgSpec,
      () => {},
      () => true,
    );
    expect(view.added).toEqual([]);
    stop(); // safe
  });

  it('after finalize, unsubscribe cancels the timer but does not touch the dead view', () => {
    const view = fakeView();
    const onChange = vi.fn();
    let finalized = false;
    const stop = watchInspectableData(view, vgSpec, onChange, () => finalized);

    view.fire('data_0');
    finalized = true; // view.finalize() ran (dropping its own listeners) before cleanup
    stop();
    vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS * 2);

    expect(onChange).not.toHaveBeenCalled();
    expect(view.removed).toEqual([]); // didn't call removeDataListener on a dead view
  });

  it('does nothing for a spec that draws no inspectable table', () => {
    const view = fakeView();
    const stop = watchInspectableData(
      view,
      { data: [], marks: [] },
      () => {},
      () => false,
    );
    expect(view.added).toEqual([]);
    stop();
  });
});
