/**
 * The data context of the active draft **at the cursor** (docs/architecture/08 →
 * editor augmentation): the columns/types/stats the spec can reference where the
 * cursor sits, and the fields the editor hints offer. One resolver, shared by the
 * transform actions (facet/repeat field defaults) and the dataset-aware hints
 * (completion/hover/inlay).
 *
 * View-scoped: a composed spec (layer/concat/facet/repeat) can bind a different
 * dataset per view, and Vega-Lite inherits a parent view's data into its children.
 * So the context is resolved at a JSON path — the data binding of the nearest
 * enclosing view (`dataBindingAtPath`), plus the fields derived by that view's and
 * its ancestors' transforms (`derivedFieldNamesAtPath`).
 *
 * Source columns, in order: a named **library dataset** the binding references
 * (its stored profile, matched case-insensitively like the renderer), else the
 * binding's **inline rows** profiled on the fly — inline `values` or a self-defined
 * top-level `datasets` entry (the "ghost dataset", `core/spec-inline-data` +
 * `core/profile`, nothing stored). URL and generator bindings have no static
 * columns.
 *
 * Profiling is memoized per (draft text, enclosing view) so the inlay provider —
 * which queries many field lines at one draft — never re-profiles the same inline
 * data. App layer — reads stores via `getState`, outside React.
 */

import { type ColumnStats, profileData } from '@core/profile';
import { pathAtOffset } from '@core/spec-cursor';
import { dataBindingAtPath, libraryRefName, selfDefinedNames } from '@core/spec-data';
import { derivedFieldNamesAtPath } from '@core/spec-fields';
import { rowsForDataBinding } from '@core/spec-inline-data';
import type { ColumnType } from '@core/type-inference';
import { useDatasetStore } from '../stores/DatasetStore';

/** A field offered to the editor hints. */
export interface FieldHint {
  name: string;
  /** Source columns carry an inferred type; a spec-derived field's is unknown. */
  type: ColumnType | null;
  /** True when introduced by a spec transform rather than the data. */
  derived: boolean;
}

/** Everything the hints need about the active draft's data at a cursor. */
export interface DataInfo {
  /** The library dataset's name, or null for inline data (the ghost dataset). */
  name: string | null;
  columnTypes: ReadonlyArray<{ name: string; type: ColumnType }>;
  columnStats: ReadonlyArray<ColumnStats>;
  /** Source columns plus derived fields, de-duplicated (source wins). */
  fields: ReadonlyArray<FieldHint>;
}

/** The source columns of one view's data binding (the expensive, cached part). */
interface SourceColumns {
  name: string | null;
  columnTypes: ReadonlyArray<{ name: string; type: ColumnType }>;
  columnStats: ReadonlyArray<ColumnStats>;
}

const EMPTY_SOURCE: SourceColumns = { name: null, columnTypes: [], columnStats: [] };
const EMPTY: DataInfo = { ...EMPTY_SOURCE, fields: [] };

function safeParse(text: string): unknown {
  try {
    return JSON.parse(text);
  } catch {
    return null;
  }
}

// The parsed draft, memoized by text — both the binding walk and the derived-field
// walk read it, and providers fire often at one stable draft.
let parseCache: { text: string; spec: unknown } | null = null;
function parsedSpec(text: string): unknown {
  if (parseCache?.text === text) return parseCache.spec;
  const spec = safeParse(text);
  parseCache = { text, spec };
  return spec;
}

/** The stored profile of the library dataset a binding references, or null. */
function libraryProfile(refName: string): SourceColumns | null {
  const lower = refName.toLowerCase();
  const ds = useDatasetStore.getState().datasets.find((d) => d.name.toLowerCase() === lower);
  if (ds && ds.columnTypes.length > 0) {
    return { name: ds.name, columnTypes: ds.columnTypes, columnStats: ds.columnStats };
  }
  return null; // unknown name, or a dataset with no profiled columns yet
}

/** Source columns for one binding: library profile, else inline rows profiled. */
function resolveSource(spec: unknown, data: unknown): SourceColumns {
  const refName = libraryRefName(data, selfDefinedNames(spec));
  if (refName !== null) return libraryProfile(refName) ?? EMPTY_SOURCE;
  const rows = rowsForDataBinding(spec, data);
  if (!rows) return EMPTY_SOURCE; // url / generator / library miss / CSV-string values
  const profile = profileData(rows, 0);
  return { name: null, columnTypes: profile.columnTypes, columnStats: profile.columnStats };
}

// Source columns memoized by (text, enclosing view). Keyed by the binding's anchor
// path so the inlay provider's many per-line queries within one view profile once.
// A re-import that changes a dataset's columns under unchanged text serves stale
// hints until the next keystroke — harmless, since hints are additive.
let sourceCache: { text: string; byAnchor: Map<string, SourceColumns> } | null = null;
function sourceColumns(
  text: string,
  spec: unknown,
  anchorKey: string,
  data: unknown,
): SourceColumns {
  if (!sourceCache || sourceCache.text !== text) sourceCache = { text, byAnchor: new Map() };
  const hit = sourceCache.byAnchor.get(anchorKey);
  if (hit) return hit;
  const result = resolveSource(spec, data);
  sourceCache.byAnchor.set(anchorKey, result);
  return result;
}

/**
 * The shared resolve step: the source columns of the binding in scope at `offset`,
 * with the parsed spec and cursor path so callers can layer derived fields on top
 * without re-parsing or re-walking.
 */
function resolveAt(
  text: string,
  offset: number,
): {
  source: SourceColumns;
  spec: unknown;
  path: ReadonlyArray<string | number>;
} {
  const spec = parsedSpec(text);
  const path = pathAtOffset(text, offset);
  const binding = dataBindingAtPath(spec, path);
  const source = binding
    ? sourceColumns(text, spec, JSON.stringify(binding.anchorPath), binding.data)
    : EMPTY_SOURCE;
  return { source, spec, path };
}

/** Source columns of the binding in scope at `offset` — no derived-field merge. */
function sourceAt(text: string, offset: number): SourceColumns {
  return text.trim() === '' ? EMPTY_SOURCE : resolveAt(text, offset).source;
}

/** The data context at `offset` in the editor's draft `text`. */
export function dataInfoAt(text: string, offset: number): DataInfo {
  if (text.trim() === '') return EMPTY;
  const { source, spec, path } = resolveAt(text, offset);

  const fields: FieldHint[] = source.columnTypes.map((c) => ({
    name: c.name,
    type: c.type,
    derived: false,
  }));
  const seen = new Set(fields.map((f) => f.name));
  for (const derived of derivedFieldNamesAtPath(spec, path)) {
    if (!seen.has(derived)) {
      fields.push({ name: derived, type: null, derived: true });
      seen.add(derived);
    }
  }

  return {
    name: source.name,
    columnTypes: source.columnTypes,
    columnStats: source.columnStats,
    fields,
  };
}

/** Source columns + derived fields available at `offset` (for completion). */
export function availableFieldsAt(text: string, offset: number): ReadonlyArray<FieldHint> {
  return dataInfoAt(text, offset).fields;
}

/** Source columns + inferred types in scope at `offset` (for facet/repeat defaults). */
export function boundColumnsAt(
  text: string,
  offset: number,
): ReadonlyArray<{ name: string; type: ColumnType }> {
  return sourceAt(text, offset).columnTypes;
}

/** The inferred type of a source field `name` at `offset`, or null (for inlay). */
export function fieldTypeAt(text: string, offset: number, name: string): ColumnType | null {
  return sourceAt(text, offset).columnTypes.find((c) => c.name === name)?.type ?? null;
}
