/**
 * Dataset library state (spec §05, docs/architecture/01 + 07).
 *
 * Holds the durable dataset collection plus the Datasets-manager view state: which
 * dataset is selected, which pane is showing (list browse, detail, the create
 * form, or the edit form), and the in-progress create/edit form with its inline
 * validation error.
 *
 * Two layers of action live here:
 *   - Low-level mutators (`add`/`update`/`remove`) are the single place the
 *     `datasets` array changes; the persistence subscriber writes them through to
 *     IndexedDB, and services (e.g. the import flow in services/transfer) reuse them.
 *   - Form orchestration (`save`) validates the form (name required + unique,
 *     data detectable) and turns it into an `add`/`update`, keeping the modal
 *     component thin.
 *
 * Persistence is NOT done here — a startup subscriber observes this store and
 * writes through to the IndexedDB adapter, so the store stays browser-free.
 */

import { create } from 'zustand';
import { detectFormat, type DataFormat } from '@core/format-detection';
import {
  createDataset,
  computeDatasetProfile,
  snapshotFromText,
  type DataSource,
  type Dataset,
} from '@core/dataset';
import { isNameTaken } from '@core/naming';
import { useSnippetStore } from './SnippetStore';

/** Which pane the Datasets manager is showing (spec §05 → Layout). */
type DatasetView = 'list' | 'detail' | 'new' | 'edit';

/** The in-progress create/edit form. `input` is the paste area (inline) or URL field. */
export interface DatasetForm {
  name: string;
  source: DataSource;
  input: string;
  comment: string;
}

const EMPTY_FORM: DatasetForm = { name: '', source: 'inline', input: '', comment: '' };

export interface DatasetState {
  datasets: Dataset[];
  selectedId: number | null;
  view: DatasetView;
  form: DatasetForm;
  /** Inline validation message for the create/edit form, or null when valid. */
  formError: string | null;

  /** Replace the library from storage. */
  hydrate: (datasets: Dataset[]) => void;
  /** Select a dataset (→ detail), or clear the selection (→ list). */
  select: (id: number | null) => void;
  /** Set the manager view directly (used by the modal's own navigation). */
  setView: (view: DatasetView) => void;
  /** Open the create form with an empty draft. */
  startCreate: () => void;
  /** Open the edit form pre-filled from the selected dataset. */
  startEdit: () => void;
  /** Patch the in-progress form (clears any stale error). */
  updateForm: (patch: Partial<DatasetForm>) => void;
  /** Leave the form, returning to the selected detail or the list. */
  cancelForm: () => void;
  /**
   * Validate and commit the current form: creates a new dataset (view `new`) or
   * updates the selected one (view `edit`), re-profiling its data and renaming
   * referencing snippets when an edit changes the name. Returns whether it
   * committed; on failure `formError` is set. `now` is injectable for tests.
   */
  save: (now?: Date) => boolean;

  /**
   * Commit a URL dataset from an already-fetched body. The component performs the
   * network fetch via the remote-data adapter and passes the result here, keeping
   * this store browser-free. Validates the form, snapshots + profiles the body, and
   * creates (view `new`) or updates (view `edit`) the dataset — including renaming
   * referencing snippets on an edit. Returns whether it committed.
   */
  commitUrlSnapshot: (fetched: { text: string }, now?: Date) => boolean;
  /**
   * Re-snapshot an existing URL dataset from a freshly-fetched body ("Refresh"):
   * re-detect the format, re-profile, and advance `fetchedAt`/`modified`. Name,
   * comment, and URL are preserved. Returns false for a non-URL dataset.
   */
  refreshDataset: (id: number, fetched: { text: string }, now?: Date) => boolean;

  /**
   * Low-level: add a fully-formed dataset and select it. The id is (re)assigned
   * via `nextDatasetId`, so a `createDataset` default id (`Date.now()`) can never
   * reach storage — see `nextDatasetId` for why that matters.
   */
  add: (dataset: Dataset) => void;
  /**
   * Append imported datasets (spec §08 → datasets imported before snippets). Each
   * is given a fresh monotonic numeric id (`nextDatasetId`, the same authority as
   * `add`) so a batch never collides on the `Date.now()` default nor with existing
   * ids — safe because datasets are referenced by **name**, not id
   * (docs/architecture/07 §1). Names are assumed already de-duped by the import
   * service. Selection is unchanged.
   */
  addDatasets: (incoming: Dataset[]) => void;
  /** Low-level: merge a patch into a dataset, advancing `modified`. */
  update: (id: number, patch: Partial<Dataset>, now?: Date) => void;
  /** Low-level: remove a dataset; clears the selection if it was selected. */
  remove: (id: number) => void;

  /** Reset to initial state (tests, future "new workspace"). */
  reset: () => void;
}

/** Newest-modified first — the manager's default ordering (spec §05 → Layout). */
export function byModifiedDesc(a: Dataset, b: Dataset): number {
  return b.modified.localeCompare(a.modified);
}

/**
 * Next free numeric id for a dataset: one past the current maximum. This store is
 * the **single id authority** — both single (`add`) and batch (`addDatasets`)
 * insertion route through it — so a `createDataset` default id (`Date.now()`)
 * never reaches IndexedDB, where a tight creation loop (the documented import path)
 * could repeat the same millisecond and collide on the numeric key. Dataset ids
 * are an internal IndexedDB key only — snippets reference datasets by **name**
 * (docs/architecture/07 §1) — so reusing an id freed by a deletion is harmless,
 * which is why one-past-the-max suffices without a persistent counter.
 */
function nextDatasetId(datasets: ReadonlyArray<Dataset>): number {
  return datasets.reduce((max, d) => Math.max(max, d.id), 0) + 1;
}

/**
 * Cheap form validation: returns an error message, or `null` when the form is
 * saveable. Deliberately does **not** `JSON.parse` the input — it only checks
 * name/input presence, name uniqueness, and that a format is detectable — so it
 * is safe to run on every render to drive the Save button's disabled state
 * (spec §05). Error copy follows the council resolution (docs/architecture/10
 * §error copy → GOV.UK error-message): action-oriented, specific, says how to fix.
 */
function validateForm(
  form: DatasetForm,
  datasets: Dataset[],
  excludeId: number | undefined,
): string | null {
  const name = form.name.trim();
  if (name === '') return 'Enter a dataset name.';
  if (isNameTaken(name, datasets, excludeId)) {
    return `A dataset named "${name}" already exists. Choose a different name.`;
  }

  const input = form.input.trim();
  if (input === '') {
    return form.source === 'url' ? 'Enter a URL.' : 'Paste JSON, CSV, or TSV data to save.';
  }

  if (form.source === 'url') {
    if (!/^https?:\/\//i.test(input)) return 'Enter a URL starting with http:// or https://.';
    return null;
  }

  // Inline: the data must auto-detect to a known format (spec §05 → Auto-detection).
  if (!detectFormat(form.input).format) return 'Enter valid JSON, CSV, or TSV data.';
  return null;
}

/**
 * Validate an **inline** form into a saveable shape, or return an error message.
 * URL datasets never reach here — they are fetched first and committed through
 * `commitUrlSnapshot` (which snapshots the fetched body), so this only shapes the
 * pasted-inline payload.
 */
function resolveForm(
  form: DatasetForm,
  datasets: Dataset[],
  excludeId: number | undefined,
): { name: string; data: unknown; format: DataFormat; source: DataSource } | { error: string } {
  const error = validateForm(form, datasets, excludeId);
  if (error) return { error };

  // validateForm guaranteed a detectable format above.
  const format = detectFormat(form.input).format as DataFormat;
  // JSON/TopoJSON are stored parsed; CSV/TSV keep their raw text (data model §09B).
  const data =
    format === 'json' || format === 'topojson' ? (JSON.parse(form.input) as unknown) : form.input;
  return { name: form.name.trim(), data, format, source: 'inline' };
}

export const useDatasetStore = create<DatasetState>((set, get) => ({
  datasets: [],
  selectedId: null,
  view: 'list',
  form: EMPTY_FORM,
  formError: null,

  hydrate: (datasets) => set({ datasets }),

  select: (id) =>
    set({
      selectedId: id,
      view: id === null ? 'list' : 'detail',
      form: EMPTY_FORM,
      formError: null,
    }),

  setView: (view) => set({ view }),

  startCreate: () => set({ view: 'new', form: EMPTY_FORM, formError: null }),

  startEdit: () => {
    const { datasets, selectedId } = get();
    const ds = datasets.find((d) => d.id === selectedId);
    if (!ds) return;
    set({
      view: 'edit',
      formError: null,
      form: {
        name: ds.name,
        source: ds.source,
        comment: ds.comment,
        // Re-render the editable text: the URL for url datasets, raw text for
        // csv/tsv, pretty-printed JSON for json/topojson.
        input:
          ds.source === 'url'
            ? (ds.url ?? '')
            : ds.format === 'csv' || ds.format === 'tsv'
              ? String(ds.data)
              : JSON.stringify(ds.data, null, 2),
      },
    });
  },

  updateForm: (patch) => set((s) => ({ form: { ...s.form, ...patch }, formError: null })),

  cancelForm: () =>
    set((s) => ({
      view: s.selectedId === null ? 'list' : 'detail',
      form: EMPTY_FORM,
      formError: null,
    })),

  save: (now) => {
    const { view, form, datasets, selectedId } = get();
    const editing = view === 'edit';
    const excludeId = editing ? (selectedId ?? undefined) : undefined;

    // URL datasets that need the network — a create, a URL change, or an inline→URL
    // conversion — are routed through `commitUrlSnapshot` after the component
    // fetches; save() never fetches. The one URL case it commits is a metadata-only
    // edit of an existing snapshot (same URL): just update name/comment, no re-fetch.
    if (form.source === 'url') {
      const error = validateForm(form, datasets, excludeId);
      if (error) {
        set({ formError: error });
        return false;
      }
      if (!editing || selectedId === null) return false;
      const existing = datasets.find((d) => d.id === selectedId);
      if (!existing || existing.source !== 'url' || form.input.trim() !== (existing.url ?? '')) {
        return false;
      }
      const name = form.name.trim();
      get().update(selectedId, { name, comment: form.comment }, now);
      if (name !== existing.name) {
        useSnippetStore.getState().renameDatasetRefs(existing.name, name, now);
      }
      set({ view: 'detail', form: EMPTY_FORM, formError: null });
      return true;
    }

    const resolved = resolveForm(form, datasets, excludeId);
    if ('error' in resolved) {
      set({ formError: resolved.error });
      return false;
    }

    if (editing && selectedId !== null) {
      const existing = datasets.find((d) => d.id === selectedId);
      if (!existing) return false;
      const profile = computeDatasetProfile(resolved.data, resolved.format);
      // Re-profile and update the record (including any new name), then propagate
      // the rename across referencing snippets so each spec and its datasetRefs
      // stay consistent (docs/architecture/07 §6). SnippetStore never imports this
      // store, so the direct call is cycle-free. Clear any url/fetchedAt left over
      // from a URL→inline conversion so the record carries no stale remote origin.
      get().update(
        selectedId,
        {
          name: resolved.name,
          data: resolved.data,
          format: resolved.format,
          source: resolved.source,
          // A URL→inline conversion must shed the remote origin; `update` deletes
          // keys set to `undefined`, so these are removed rather than left
          // present-but-undefined.
          url: undefined,
          fetchedAt: undefined,
          comment: form.comment,
          ...profile,
        },
        now,
      );
      if (resolved.name !== existing.name) {
        useSnippetStore.getState().renameDatasetRefs(existing.name, resolved.name, now);
      }
      set({ view: 'detail', form: EMPTY_FORM, formError: null });
      return true;
    }

    const dataset = createDataset({
      name: resolved.name,
      data: resolved.data,
      format: resolved.format,
      source: resolved.source,
      comment: form.comment,
      now,
    });
    get().add(dataset);
    // Switching to the detail view with the new dataset selected IS the success
    // confirmation, so no toast is raised — the result is on-screen (spec §05;
    // docs/architecture/10 → Toast copy). Extract-to-dataset, which creates a
    // dataset off-screen, does toast (see ExtractStore).
    set({ view: 'detail', form: EMPTY_FORM, formError: null });
    return true;
  },

  commitUrlSnapshot: (fetched, now) => {
    const { view, form, datasets, selectedId } = get();
    const editing = view === 'edit';
    const excludeId = editing ? (selectedId ?? undefined) : undefined;

    const error = validateForm(form, datasets, excludeId);
    if (error) {
      set({ formError: error });
      return false;
    }

    const name = form.name.trim();
    const url = form.input.trim();
    const iso = (now ?? new Date()).toISOString();
    // The fetched body snapshots + profiles exactly like inline data (snapshot
    // model): detect format from content, shape, and profile through core.
    const { data, format } = snapshotFromText(fetched.text, url);

    if (editing && selectedId !== null) {
      const existing = datasets.find((d) => d.id === selectedId);
      if (!existing) return false;
      const profile = computeDatasetProfile(data, format);
      get().update(
        selectedId,
        {
          name,
          data,
          format,
          source: 'url',
          url,
          fetchedAt: iso,
          comment: form.comment,
          ...profile,
        },
        now,
      );
      if (name !== existing.name) {
        useSnippetStore.getState().renameDatasetRefs(existing.name, name, now);
      }
      set({ view: 'detail', form: EMPTY_FORM, formError: null });
      return true;
    }

    const dataset = createDataset({
      name,
      data,
      format,
      source: 'url',
      url,
      fetchedAt: iso,
      comment: form.comment,
      now,
    });
    get().add(dataset);
    set({ view: 'detail', form: EMPTY_FORM, formError: null });
    return true;
  },

  refreshDataset: (id, fetched, now) => {
    const dataset = get().datasets.find((d) => d.id === id);
    if (!dataset || dataset.source !== 'url' || !dataset.url) return false;
    const iso = (now ?? new Date()).toISOString();
    const { data, format } = snapshotFromText(fetched.text, dataset.url);
    const profile = computeDatasetProfile(data, format);
    // Name, comment, and url are preserved; only the snapshot + its profile and the
    // fetch time change. `update` advances `modified`.
    get().update(id, { data, format, fetchedAt: iso, ...profile }, now);
    return true;
  },

  add: (dataset) =>
    set((s) => {
      const withId = { ...dataset, id: nextDatasetId(s.datasets) };
      return { datasets: [withId, ...s.datasets], selectedId: withId.id };
    }),

  addDatasets: (incoming) => {
    if (incoming.length === 0) return;
    set((s) => {
      let nextId = nextDatasetId(s.datasets);
      const withIds = incoming.map((d) => ({ ...d, id: nextId++ }));
      return { datasets: [...withIds, ...s.datasets] };
    });
  },

  update: (id, patch, now) => {
    const modified = patch.modified ?? (now ?? new Date()).toISOString();
    set((s) => ({
      datasets: s.datasets.map((d) => {
        if (d.id !== id) return d;
        const merged = { ...d, ...patch, modified };
        // A patch key set to `undefined` removes it, rather than leaving a
        // present-but-undefined key — so a URL→inline conversion drops `url`/
        // `fetchedAt` cleanly, keeping the "inline records carry no remote-origin
        // keys" invariant (docs/architecture/07 §6).
        for (const key of Object.keys(patch) as (keyof Dataset)[]) {
          if (patch[key] === undefined) delete merged[key];
        }
        return merged;
      }),
    }));
  },

  remove: (id) =>
    set((s) => ({
      datasets: s.datasets.filter((d) => d.id !== id),
      selectedId: s.selectedId === id ? null : s.selectedId,
      view: s.selectedId === id ? 'list' : s.view,
      form: s.selectedId === id ? EMPTY_FORM : s.form,
    })),

  reset: () =>
    set({ datasets: [], selectedId: null, view: 'list', form: EMPTY_FORM, formError: null }),
}));

/** Selector: the selected dataset record, or null. Derive — never store. */
export const selectSelectedDataset = (s: DatasetState): Dataset | null =>
  s.datasets.find((d) => d.id === s.selectedId) ?? null;

/**
 * Selector: whether the current form is saveable, powering the Save button's
 * disabled state (spec §05 → Save is disabled until a name and valid data/URL
 * are present). Mirrors the `save()` action's `excludeId` derivation so an edit
 * can keep its own name.
 */
export const selectCanSave = (s: DatasetState): boolean =>
  validateForm(s.form, s.datasets, s.view === 'edit' ? (s.selectedId ?? undefined) : undefined) ===
  null;
