/**
 * Snippet — the primary user-authored entity (spec §09A).
 *
 * Portable core: no browser APIs, no React. Defines the record shape, the
 * current record schema version, and pure factories for creating new snippets
 * (with the sample bar-chart template and an auto-generated date/time name).
 *
 * Specs are stored as **JSON text** (string). The data model permits a spec to
 * be an object or a string; we standardize on the string form because it is what
 * the Monaco editor edits and what survives round-tripping without reformatting.
 * The preview parses the text into an object before rendering (see rendering.ts).
 */

/** Current schema version for a Snippet record (read-time migration target). */
export const CURRENT_SNIPPET_VERSION = 1;

/**
 * The Vega-Lite schema URL stamped into generated specs (`$schema`). Shared so the
 * sample template and the Chart Builder agree on one version; the Monaco schema
 * service pins the same URI independently (infrastructure/monaco-schema.ts).
 */
export const VEGA_LITE_SCHEMA_URL = 'https://vega.github.io/schema/vega-lite/v6.json';

export interface Snippet {
  /** Unique, stable identifier. */
  id: string;
  /** Record schema version, for read-time migration. */
  version: number;
  /** Human-readable title shown in the library. */
  name: string;
  /**
   * Name provenance — the naming hierarchy's gate. `'user'`: explicitly chosen
   * (rename / metadata panel) — frozen, never rewritten. `'auto'`: app-picked
   * (timestamp default, builder-generated, or publish-derived) — keeps tracking
   * the spec's content on publish. Absent on records from before the field
   * existed; `isAutoNamed` then falls back to recognizing the timestamp shape.
   */
  nameSource?: 'auto' | 'user';
  /** ISO timestamp — when first created. */
  created: string;
  /** ISO timestamp — when last saved. */
  modified: string;
  /** The published (stable) Vega-Lite spec, as JSON text. */
  spec: string;
  /** The working-draft Vega-Lite spec being edited, as JSON text. */
  draftSpec: string;
  /** Free-form user note. */
  comment: string;
  /** User-assigned labels. */
  tags: string[];
  /** Names of datasets referenced by the draft spec; mirrors the version being
   *  edited, recomputed on every draft change (create/auto-save/extract/revert)
   *  and on publish (docs/architecture/07 §3). */
  datasetRefs: string[];
  /** Free-form, extensible metadata bag. */
  meta: Record<string, unknown>;
}

/**
 * The sample bar-chart template a fresh snippet starts from (spec §02 → Create
 * New: "a small sample Vega-Lite bar-chart template with a few inline rows").
 * Inline data only — datasets arrive in M3.
 */
export const SAMPLE_SPEC = {
  $schema: VEGA_LITE_SCHEMA_URL,
  description: 'A simple bar chart.',
  data: {
    values: [
      { category: 'A', value: 28 },
      { category: 'B', value: 55 },
      { category: 'C', value: 43 },
      { category: 'D', value: 91 },
      { category: 'E', value: 81 },
    ],
  },
  mark: 'bar',
  encoding: {
    x: { field: 'category', type: 'nominal', axis: { labelAngle: 0 } },
    y: { field: 'value', type: 'quantitative' },
  },
} as const;

/** The sample template rendered as pretty-printed JSON text. */
export function sampleSpecText(): string {
  return JSON.stringify(SAMPLE_SPEC, null, 2);
}

/** Two-digit zero-pad for the date/time name. */
function pad(n: number): string {
  return String(n).padStart(2, '0');
}

/**
 * Auto-generated default name from a timestamp, e.g. "Snippet 2026-06-04 14:30:07".
 * Including seconds keeps names unique for snippets created in quick succession.
 */
export function generateSnippetName(now: Date): string {
  const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
  const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
  return `Snippet ${date} ${time}`;
}

/** Whether `name` is (still) an untouched `generateSnippetName` auto-default. */
export function isDefaultSnippetName(name: string): boolean {
  return /^Snippet \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(name.trim());
}

/**
 * Whether the snippet's name is app-picked (rewritable on publish) rather than
 * user-chosen (frozen). Records predating `nameSource` carry no provenance, so
 * the rule is: never rewrite a name we can't prove the user didn't choose. Two
 * shapes are provable — the timestamp default, and a name identical to what
 * the app derives from the snippet's own published spec (only the generator
 * produces that string for that spec; a user typing it verbatim is naming the
 * content, which is what tracking preserves).
 */
export function isAutoNamed(snippet: Pick<Snippet, 'name' | 'nameSource' | 'spec'>): boolean {
  if (snippet.nameSource !== undefined) return snippet.nameSource === 'auto';
  if (isDefaultSnippetName(snippet.name)) return true;
  return deriveSnippetName(snippet.spec) === snippet.name;
}

/** A channel's encoding definition, as far as naming cares about it. */
interface EncodingDef {
  field?: unknown;
  aggregate?: unknown;
  value?: unknown;
}

/** A human phrase for what an encoding channel shows, e.g. "sum of revenue". */
function describeEncoding(def: EncodingDef): string | null {
  if (def.value !== undefined) return null; // a constant — nothing to name
  const aggregate = typeof def.aggregate === 'string' ? def.aggregate : undefined;
  if (aggregate === 'count') return 'count';
  const field = typeof def.field === 'string' ? def.field.trim() : '';
  if (field === '') return null; // repeat refs / missing field — not nameable
  if (aggregate === 'distinct') return `unique ${field}`;
  if (aggregate) return `${aggregate} of ${field}`;
  return field;
}

/**
 * Derive a descriptive name from a spec's content — `"Bar chart of <y> by <x>"`
 * (the same dialect as the Chart Builder's `generateChartName`, so manually
 * authored and builder-built snippets read alike in the library; NN/g #6
 * recognition-over-recall). A spec-level `title` wins verbatim. Returns `null`
 * when the spec is unparseable or carries too little to describe (no usable
 * mark/encodings) — callers keep the existing name then.
 */
export function deriveSnippetName(specText: string): string | null {
  let spec: unknown;
  try {
    spec = JSON.parse(specText);
  } catch {
    return null;
  }
  if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) return null;
  const s = spec as Record<string, unknown>;

  // Vega-Lite titles are a string, an array of lines, or a params object whose
  // `text` is either; all collapse to one line here.
  const titleText = (value: unknown): string => {
    if (typeof value === 'string') return value.trim();
    if (Array.isArray(value))
      return value
        .filter((line): line is string => typeof line === 'string')
        .map((line) => line.trim())
        .filter(Boolean)
        .join(' ');
    if (typeof value === 'object' && value !== null)
      return titleText((value as { text?: unknown }).text);
    return '';
  };
  const title = titleText(s.title);
  if (title) return title;

  const markRaw =
    typeof s.mark === 'string'
      ? s.mark
      : typeof s.mark === 'object' && s.mark !== null
        ? (s.mark as { type?: unknown }).type
        : undefined;
  if (typeof markRaw !== 'string' || markRaw === '') return null;
  const mark = markRaw.charAt(0).toUpperCase() + markRaw.slice(1);

  const encoding =
    typeof s.encoding === 'object' && s.encoding !== null
      ? (s.encoding as Record<string, unknown>)
      : {};
  const phraseFor = (channel: string): string | null => {
    const def = encoding[channel];
    if (typeof def !== 'object' || def === null) return null;
    return describeEncoding(def);
  };

  const x = phraseFor('x');
  const y = phraseFor('y');
  if (x && y) return `${mark} chart of ${y} by ${x}`;
  const only = x ?? y ?? phraseFor('theta') ?? phraseFor('color');
  if (only) return `${mark} chart of ${only}`;
  return null;
}

export interface CreateSnippetOptions {
  /** Override the auto-generated name. */
  name?: string;
  /**
   * Name provenance override. Defaults to `'user'` when `name` is given (an
   * explicit name is presumed chosen) and `'auto'` for the timestamp default;
   * generators passing a derived `name` (the Chart Builder) say `'auto'` so the
   * name keeps tracking the spec until the user renames.
   */
  nameSource?: 'auto' | 'user';
  /** Override the starting spec text (defaults to the sample template). */
  spec?: string;
  /** Clock injection for deterministic tests; defaults to the current time. */
  now?: Date;
  /** Id injection for deterministic tests; defaults to a random UUID. */
  id?: string;
  /** Seed the extensible metadata bag (e.g. Chart Builder provenance). */
  meta?: Record<string, unknown>;
}

/**
 * Create a new snippet. `spec` and `draftSpec` start identical (nothing to
 * publish yet); timestamps are equal at creation.
 */
export function createSnippet(options: CreateSnippetOptions = {}): Snippet {
  const now = options.now ?? new Date();
  const iso = now.toISOString();
  const spec = options.spec ?? sampleSpecText();
  return {
    id: options.id ?? crypto.randomUUID(),
    version: CURRENT_SNIPPET_VERSION,
    name: options.name ?? generateSnippetName(now),
    nameSource: options.nameSource ?? (options.name !== undefined ? 'user' : 'auto'),
    created: iso,
    modified: iso,
    spec,
    draftSpec: spec,
    comment: '',
    tags: [],
    datasetRefs: [],
    meta: options.meta ?? {},
  };
}

export interface DuplicateSnippetOptions {
  /** Clock injection for deterministic tests; defaults to the current time. */
  now?: Date;
  /** Id injection for deterministic tests; defaults to a random UUID. */
  id?: string;
}

/**
 * Create an independent copy of a snippet (spec §02 → Duplicate). The copy carries
 * over the specification (both published and draft), comment, tags, and dataset
 * references, gets a new identity and fresh created/modified timestamps, and a name
 * suffixed "(copy)". Mutable members are cloned so the copy shares no references
 * with its source.
 */
export function duplicateSnippet(source: Snippet, options: DuplicateSnippetOptions = {}): Snippet {
  const iso = (options.now ?? new Date()).toISOString();
  return {
    ...source,
    id: options.id ?? crypto.randomUUID(),
    version: CURRENT_SNIPPET_VERSION,
    name: `${source.name} (copy)`,
    created: iso,
    modified: iso,
    tags: [...source.tags],
    datasetRefs: [...source.datasetRefs],
    meta: { ...source.meta },
  };
}

/** True when the snippet's draft differs from its published spec (§03D). */
export function hasUnpublishedChanges(snippet: Snippet): boolean {
  return snippet.draftSpec !== snippet.spec;
}

/**
 * Approximate payload size of a snippet, in bytes (spec §09 `size`). Measured on
 * the working draft — the version the editor and preview currently show — as its
 * UTF-8 byte length, so the figure tracks what the user is actually editing.
 * `TextEncoder` is a platform global (like `crypto.randomUUID` above), not a
 * browser/DOM API, so it stays within the portable core.
 */
export function snippetSizeBytes(snippet: Snippet): number {
  return new TextEncoder().encode(snippet.draftSpec).length;
}

/** Below this we omit the size in the library to reduce clutter (spec §02). */
const SIZE_DISPLAY_THRESHOLD = 1024;

/**
 * Human-readable size for the library row, or `null` when the snippet is small
 * enough that the spec says to omit it (under ~1 KB). Rounded to whole KB/MB —
 * a list hint, not a precise measure.
 */
export function formatSnippetSize(bytes: number): string | null {
  if (bytes < SIZE_DISPLAY_THRESHOLD) return null;
  const kb = bytes / 1024;
  if (kb < 1024) return `${Math.round(kb)} KB`;
  return `${Math.round(kb / 1024)} MB`;
}
