/**
 * Import normalization — shape detection, per-record normalization to the current
 * model, and pure merge helpers (spec §08 → Import; docs/architecture/07 §5, §6).
 *
 * Portable core: no browser APIs, no React, no store/file/IndexedDB access. Plain
 * data in, plain data out. The app's ImportService orchestrates the file read,
 * store reads (for existing names/ids), and the commit; everything *deterministic*
 * about an import — recognizing the file shape, coercing each record onto the
 * current `Snippet`/`Dataset`/`CustomTheme` shape, de-duping names, reassigning
 * colliding ids, and propagating renames into specs — lives here so it can be
 * unit-tested hardest.
 *
 * `crypto.randomUUID` is a platform global (like in snippet.ts), allowed in core.
 * Both id generators are injectable so tests can assert deterministically.
 */

import { CURRENT_THEME_VERSION, type CustomTheme } from './custom-theme';
import { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from './dataset';
import { deserializeFontAsset, type FontAsset } from './font-asset';
import type { DataFormat } from './format-detection';
import { makeUniqueName } from './naming';
import type { ColumnStats } from './profile';
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
import { isJsonObject } from './spec-config';
import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from './spec-refs';
import type { ColumnType } from './type-inference';

/** The tag stamped onto foreign/older snippets so the user can find imports. */
const IMPORTED_TAG = 'imported';

/** Result of normalizing a parsed import payload onto the current model. */
export interface NormalizedImport {
  snippets: Snippet[];
  datasets: Dataset[];
  themes: CustomTheme[];
  fonts: FontAsset[];
}

/** A single rename applied during name dedupe (`from` original → `to` unique). */
export interface NameRename {
  from: string;
  to: string;
}

export interface NormalizeImportOptions {
  /** Clock injection for generated timestamps; defaults to the current time. */
  now?: Date;
  /** Id injection for generated snippet ids; defaults to `crypto.randomUUID`. */
  makeId?: () => string;
}

// ----------------------------------------------------------------------------
// Small shape helpers (kept private; mirror migrateSnippet's fallback approach
// without importing from app/infrastructure — core cannot depend on app).
// ----------------------------------------------------------------------------

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/** Coerce a stored spec field (object or string) into the canonical string form. */
function asSpecText(value: unknown, fallback: string): string {
  if (typeof value === 'string') return value;
  if (value == null) return fallback;
  try {
    return JSON.stringify(value, null, 2);
  } catch {
    return fallback;
  }
}

/**
 * An ISO-ish creation timestamp marks an already-current Astrolabe snippet. We
 * accept the common ISO 8601 lead-in `YYYY-MM-DDTHH:MM` (with optional seconds,
 * fractional seconds, and zone) — the shape `Date#toISOString` produces. We do
 * not require the trailing `Z`/offset so a hand-edited but clearly-current
 * timestamp still counts; we deliberately reject a bare `YYYY-MM-DD` date, which
 * older/foreign shapes use, so those still get normalized + tagged.
 */
function isIsoTimestamp(value: unknown): value is string {
  return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value);
}

/** A trimmed, non-empty string, or undefined — used to derive a source timestamp. */
function asNonEmptyString(value: unknown): string | undefined {
  return typeof value === 'string' && value.trim() !== '' ? value : undefined;
}

// ----------------------------------------------------------------------------
// Per-record normalization
// ----------------------------------------------------------------------------

/**
 * Normalize one raw snippet record onto the current `Snippet` shape.
 *
 *   - Already-current (carries an ISO `created`): preserve existing fields with
 *     fallbacks; do NOT add the "imported" tag.
 *   - Foreign/older: map alternative field names (`content` → spec, `draft` →
 *     draftSpec, `createdAt` → created), generate missing timestamps, fill all
 *     gaps with defaults, and ensure the "imported" tag is present.
 *
 * Specs are coerced to canonical JSON-string form; draftSpec falls back to spec.
 * `version` is always stamped to the current value (a missing/old version is the
 * earliest shape, migrated up here).
 */
function normalizeSnippet(raw: unknown, nowIso: string, makeId: () => string): Snippet {
  const r = isPlainObject(raw) ? raw : {};

  // Field mapping for foreign shapes: prefer the current field, fall back to the
  // alternative name (`content` → spec, `draft` → draftSpec, `createdAt` → created).
  const specSource = r.spec ?? r.content;
  const draftSource = r.draftSpec ?? r.draft;
  const createdSource = asNonEmptyString(r.created) ?? asNonEmptyString(r.createdAt);

  const isCurrent = isIsoTimestamp(r.created);

  const spec = asSpecText(specSource, '{}');
  const draftSpec = asSpecText(draftSource, spec);

  // Timestamps: a current record keeps its ISO `created`; otherwise derive from a
  // present source timestamp (created/createdAt), else fall back to `now`.
  const created = isCurrent ? (r.created as string) : (createdSource ?? nowIso);
  const modified = asNonEmptyString(r.modified) ?? created;

  // Tags: preserve any existing tags; for foreign/older shapes ensure "imported"
  // is present (without duplicating it).
  const existingTags = Array.isArray(r.tags)
    ? (r.tags as unknown[]).filter((t): t is string => typeof t === 'string')
    : [];
  const tags = isCurrent
    ? existingTags
    : existingTags.includes(IMPORTED_TAG)
      ? existingTags
      : [...existingTags, IMPORTED_TAG];

  const datasetRefs = Array.isArray(r.datasetRefs)
    ? (r.datasetRefs as unknown[]).filter((d): d is string => typeof d === 'string')
    : [];

  return {
    id: typeof r.id === 'string' && r.id !== '' ? r.id : makeId(),
    version: CURRENT_SNIPPET_VERSION,
    name: typeof r.name === 'string' ? r.name : 'Untitled',
    // Preserve name provenance from our own envelopes; anything else stays
    // undefined → isAutoNamed's conservative timestamp-shape fallback.
    ...(r.nameSource === 'auto' || r.nameSource === 'user' ? { nameSource: r.nameSource } : {}),
    created,
    modified,
    spec,
    draftSpec,
    comment: typeof r.comment === 'string' ? r.comment : '',
    tags,
    datasetRefs,
    meta: isPlainObject(r.meta) ? r.meta : {},
  };
}

/**
 * Normalize one raw dataset record onto the current `Dataset` shape. Envelope
 * datasets already carry their derived summary fields (rowCount, columns, …); we
 * preserve those and only fill gaps. We do NOT re-profile here — that needs the
 * profiling pipeline and would be wasteful for already-summarized records.
 *
 * Applies the v1→v2 URL-snapshot shaping (mirrors `migrateDataset`): a pre-v2 export
 * stored a URL dataset's address in `data`, so we move it into `url` and clear `data`
 * to `null` — importing such a record yields an unfetched reference, not a record
 * with a URL string masquerading as its snapshot.
 */
function normalizeDataset(raw: unknown, nowIso: string): Dataset {
  const r = isPlainObject(raw) ? raw : {};

  const created = asNonEmptyString(r.created) ?? nowIso;
  const modified = asNonEmptyString(r.modified) ?? created;

  const source = (typeof r.source === 'string' ? r.source : 'inline') as DataSource;
  const legacyUrl = source === 'url' && typeof r.url !== 'string';
  const url = legacyUrl
    ? typeof r.data === 'string'
      ? r.data
      : undefined
    : typeof r.url === 'string'
      ? r.url
      : undefined;
  const fetchedAt = legacyUrl ? null : typeof r.fetchedAt === 'string' ? r.fetchedAt : null;

  return {
    id: typeof r.id === 'number' ? r.id : Number(r.id) || 0,
    version: CURRENT_DATASET_VERSION,
    name: typeof r.name === 'string' ? r.name : 'Untitled',
    data: legacyUrl ? null : r.data,
    format: (typeof r.format === 'string' ? r.format : 'json') as DataFormat,
    source,
    ...(source === 'url' ? { url, fetchedAt } : {}),
    comment: typeof r.comment === 'string' ? r.comment : '',
    rowCount: typeof r.rowCount === 'number' ? r.rowCount : null,
    columnCount: typeof r.columnCount === 'number' ? r.columnCount : null,
    columns: Array.isArray(r.columns)
      ? (r.columns as unknown[]).filter((c): c is string => typeof c === 'string')
      : [],
    columnTypes: Array.isArray(r.columnTypes)
      ? (r.columnTypes as Array<{ name: string; type: ColumnType }>)
      : [],
    columnStats: Array.isArray(r.columnStats) ? (r.columnStats as ColumnStats[]) : [],
    size: typeof r.size === 'number' ? r.size : 0,
    created,
    modified,
  };
}

/**
 * Normalize one raw custom-theme record onto the current `CustomTheme` shape
 * (spec §08 → Accepted inputs). Themes only travel inside Astrolabe envelopes,
 * so this is gap-filling rather than foreign-shape mapping: a non-object
 * `config` becomes `{}`, missing timestamps derive like datasets, and `version`
 * is stamped current. Ids are provisional — the store's id authority reassigns
 * them on insert, so collisions need no handling here.
 */
function normalizeCustomTheme(raw: unknown, nowIso: string): CustomTheme {
  const r = isPlainObject(raw) ? raw : {};

  const created = asNonEmptyString(r.created) ?? nowIso;
  const modified = asNonEmptyString(r.modified) ?? created;

  return {
    id: typeof r.id === 'number' ? r.id : Number(r.id) || 0,
    version: CURRENT_THEME_VERSION,
    name: typeof r.name === 'string' ? r.name : 'Untitled',
    config: isJsonObject(r.config) ? r.config : {},
    created,
    modified,
  };
}

// ----------------------------------------------------------------------------
// Shape detection (spec §08 "Accepted inputs")
// ----------------------------------------------------------------------------

/**
 * Detect the import shape and normalize every record onto the current model.
 *
 *   - Envelope: object with a `version` AND a `snippets` array → its snippets
 *     (+ optional `datasets`, `themes`, and `fonts` arrays).
 *   - Bare array: a top-level array → a list of snippets, no datasets/themes/fonts.
 *   - Single object: any other object → one snippet, no datasets/themes/fonts.
 *   - junk (null / non-object / non-array) → empty.
 */
export function normalizeImport(
  parsed: unknown,
  opts: NormalizeImportOptions = {},
): NormalizedImport {
  const now = opts.now ?? new Date();
  const nowIso = now.toISOString();
  const makeId = opts.makeId ?? (() => crypto.randomUUID());

  let rawSnippets: unknown[] = [];
  let rawDatasets: unknown[] = [];
  let rawThemes: unknown[] = [];
  let rawFonts: unknown[] = [];

  if (Array.isArray(parsed)) {
    // Bare array of snippets.
    rawSnippets = parsed;
  } else if (isPlainObject(parsed)) {
    const hasEnvelope = 'version' in parsed && Array.isArray(parsed.snippets);
    if (hasEnvelope) {
      rawSnippets = parsed.snippets as unknown[];
      if (Array.isArray(parsed.datasets)) rawDatasets = parsed.datasets;
      if (Array.isArray(parsed.themes)) rawThemes = parsed.themes;
      if (Array.isArray(parsed.fonts)) rawFonts = parsed.fonts;
    } else {
      // Single snippet object.
      rawSnippets = [parsed];
    }
  }
  // else: null / non-object / non-array junk → all stay empty.

  return {
    snippets: rawSnippets.map((s) => normalizeSnippet(s, nowIso, makeId)),
    datasets: rawDatasets.map((d) => normalizeDataset(d, nowIso)),
    themes: rawThemes.map((t) => normalizeCustomTheme(t, nowIso)),
    // Unusable font records (no family / bad base64) decode to null and are dropped.
    fonts: rawFonts
      .map((f) => deserializeFontAsset(f, { now }))
      .filter((f): f is FontAsset => f !== null),
  };
}

// ----------------------------------------------------------------------------
// Merge helpers
// ----------------------------------------------------------------------------

/**
 * De-dupe incoming record names against the existing collection and within the
 * batch itself (architecture 07 §5) — used for datasets and custom themes, both
 * of which key on a unique name. Names are reserved *as we go* so two incoming
 * `Sales` become `Sales 2`, `Sales 3` — never two `Sales 2`. Existing records are
 * never overwritten. Every rename is collected for reporting. Only records whose
 * name actually changed are cloned.
 */
export function dedupeIncomingNames<T extends { name: string }>(
  existing: ReadonlyArray<string>,
  incoming: ReadonlyArray<T>,
): { records: T[]; renames: NameRename[] } {
  const reserved = new Set(existing.map((n) => n.toLowerCase()));
  const renames: NameRename[] = [];

  const records = incoming.map((r) => {
    const unique = makeUniqueName(r.name, reserved);
    reserved.add(unique.toLowerCase()); // reserve so later imports don't collide
    if (unique !== r.name) renames.push({ from: r.name, to: unique });
    return unique === r.name ? r : { ...r, name: unique };
  });

  return { records, renames };
}

/**
 * Drop incoming fonts whose `family` already exists (case-insensitive) — in the
 * library or earlier in the same batch. The fonts merge rule (spec §08 → Name
 * conflicts; scope doc §4): unlike datasets/themes (renamed on clash), a clashing
 * uploaded face is **skipped**, not renamed — a font is identified by its family,
 * which is the key embedded in config font slots, so a same-named existing face
 * already satisfies any incoming reference. This also dedupes a self-backup →
 * restore (no "Font 2" copies pile up). Returns the kept records and the skipped
 * family names (for reporting).
 */
export function dropClashingFonts(
  existingFamilies: ReadonlyArray<string>,
  incoming: ReadonlyArray<FontAsset>,
): { records: FontAsset[]; skipped: string[] } {
  const seen = new Set(existingFamilies.map((n) => n.toLowerCase()));
  const records: FontAsset[] = [];
  const skipped: string[] = [];
  for (const font of incoming) {
    const key = font.family.toLowerCase();
    if (seen.has(key)) {
      skipped.push(font.family);
      continue;
    }
    seen.add(key);
    records.push(font);
  }
  return { records, skipped };
}

/**
 * Reassign ids for incoming snippets whose id already exists (spec §08 "ID
 * collisions"). The existing snippet keeps its id; the incoming one gets a fresh
 * unique id. Ids are reserved *as we go* so two incoming snippets sharing one id
 * both get distinct fresh ids — and a freshly-minted id can't collide with another
 * incoming snippet either. Only snippets that get a new id are cloned.
 */
export function reassignCollidingSnippetIds(
  existingIds: ReadonlyArray<string>,
  incoming: ReadonlyArray<Snippet>,
  makeId: () => string = () => crypto.randomUUID(),
): Snippet[] {
  const reserved = new Set(existingIds);

  return incoming.map((s) => {
    if (!reserved.has(s.id)) {
      reserved.add(s.id);
      return s;
    }
    let fresh = makeId();
    while (reserved.has(fresh)) fresh = makeId();
    reserved.add(fresh);
    return { ...s, id: fresh };
  });
}

/**
 * Propagate dataset renames (from `dedupeIncomingNames`) into the imported
 * snippets that reference them (architecture 07 §6): for each rename, rewrite both
 * `spec` and `draftSpec` via `renameDatasetInSpec`, then recompute `datasetRefs`
 * from the rewritten draft — the draft is the source of truth, `datasetRefs`
 * mirrors it. Matching is case-insensitive (consistent with the naming helpers). Only
 * snippets that actually change are cloned.
 *
 * Applied to the imported set ONLY — existing snippets keep their references,
 * because existing datasets were never renamed (collisions suffix the incoming).
 */
export function applyDatasetRenamesToSnippets(
  snippets: ReadonlyArray<Snippet>,
  renames: ReadonlyArray<NameRename>,
): Snippet[] {
  if (renames.length === 0) return snippets.slice();

  return snippets.map((snippet) => {
    // The names this snippet actually references: what the specs literally
    // reference (spec casing first, so `renameDatasetInSpec`'s case-sensitive
    // match gets the right oldName) unioned with the stored `datasetRefs` mirror.
    // Consulting the spec — not only datasetRefs — makes propagation robust when a
    // hand-crafted/foreign import references a dataset in its spec without a
    // matching datasetRefs entry; the renderer resolves by spec, so a missed
    // rename would otherwise break rendering (arch 07 §3/§6).
    const referenced = [
      ...extractDatasetRefs(snippet.spec),
      ...extractDatasetRefs(snippet.draftSpec),
      ...snippet.datasetRefs,
    ];
    const targets: Array<{ oldName: string; to: string }> = [];
    for (const { from, to } of renames) {
      const ref = referenced.find((r) => r.toLowerCase() === from.toLowerCase());
      if (ref !== undefined) targets.push({ oldName: ref, to });
    }
    if (targets.length === 0) return snippet;

    let spec = snippet.spec;
    let draftSpec = snippet.draftSpec;
    for (const { oldName, to } of targets) {
      spec = renameDatasetInSpec(spec, oldName, to);
      draftSpec = renameDatasetInSpec(draftSpec, oldName, to);
    }

    // datasetRefs mirrors the draft — the version the editor shows (arch 07 §3).
    return { ...snippet, spec, draftSpec, datasetRefs: recomputeDatasetRefs(draftSpec) };
  });
}
