/**
 * Snippet library state (docs/architecture/01).
 *
 * Holds the durable domain state for the library: the snippets, which one is
 * active, and the live editor buffer (`draftText`). Actions are the single place
 * snippet state mutates, so they are unit-testable without a DOM or IndexedDB.
 *
 * Persistence is NOT done here — a startup subscriber
 * (orchestration/snippet-persistence) observes this store and writes through to
 * the IndexedDB adapter. That keeps the store pure and free of browser APIs.
 *
 * Draft/published model (spec §03D): every snippet carries a published `spec`
 * and a working `draftSpec`. Ordinary editing — keystrokes, auto-save — touches
 * the **draft** only; `publish` promotes the draft to published, `revert`
 * discards it back to published. `editorView` selects which version the editor
 * shows (and therefore which the preview renders); the live editable buffer
 * `draftText` always mirrors the draft, regardless of view.
 */

import { create } from 'zustand';
import {
  createSnippet,
  deriveSnippetName,
  duplicateSnippet as duplicateSnippetRecord,
  isAutoNamed,
  type CreateSnippetOptions,
  type Snippet,
} from '@core/snippet';
import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from '@core/spec-refs';
import {
  DEFAULT_SORT_BY,
  DEFAULT_SORT_ORDER,
  type SortBy,
  type SortOrder,
} from '@core/snippet-sort';

/** Which version of the active snippet the editor is showing (spec §03D). */
export type EditorView = 'draft' | 'published';

export interface SnippetState {
  snippets: Snippet[];
  activeSnippetId: string | null;
  /** The live Monaco buffer for the active snippet's draft (may be mid-edit/invalid). */
  draftText: string;
  /** Which version the editor shows; `'published'` is read-only (spec §03D). */
  editorView: EditorView;
  /**
   * Bumped whenever the buffer is loaded *programmatically* (hydrate, select,
   * create, delete-fallback, revert) — never on keystrokes. The editor reloads
   * its content when this changes, so an external buffer load (e.g. revert)
   * refreshes the editor without setValue fighting the cursor mid-typing.
   */
  bufferEpoch: number;

  /**
   * Library view state (spec §02 → Search / Sort). These affect only which
   * snippets are shown and in what order — never `activeSnippetId` or any data.
   * The component derives its visible list from these primitives via `useMemo`
   * (core `filterAndSortSnippets`), NOT through a selector that builds a fresh
   * array (which would loop the app — MEMORY → "Zustand stable selectors").
   */
  searchQuery: string;
  sortBy: SortBy;
  sortOrder: SortOrder;

  /** Replace the library from storage and choose an active snippet. */
  hydrate: (snippets: Snippet[], activeId?: string | null) => void;
  /**
   * Append imported snippets (spec §08 → merge: appended, never overwritten). Ids
   * are assumed already unique against the library (the import service reassigns
   * collisions). Keeps the current selection; selects the newest import only when
   * nothing is active, so an import into an empty workspace lands the user on it.
   */
  addSnippets: (incoming: Snippet[]) => void;
  /** Create a new snippet (sample template by default), prepend, and select it. */
  createSnippet: (options?: CreateSnippetOptions) => string;
  /** Make a snippet active and load its draft into the editor buffer. */
  selectSnippet: (id: string) => void;
  /** Remove a snippet; if it was active, fall back to the newest remaining one. */
  removeSnippet: (id: string) => void;
  /**
   * Rename a snippet (spec §02 metadata panel, inline name edit). Advances
   * `modified` (a name edit is a save, §02 → Sort), but never touches the editor
   * buffer — the name isn't part of the spec text. No-op for an unknown id or an
   * unchanged name. `now` injectable.
   */
  renameSnippet: (id: string, name: string, now?: Date) => void;
  /**
   * Set a snippet's free-form comment (spec §02 metadata panel). Advances
   * `modified` like a rename; no editor-buffer effect. No-op for an unknown id or
   * an unchanged comment. `now` injectable.
   */
  setComment: (id: string, comment: string, now?: Date) => void;
  /** Set the live search query (spec §02 → Search). Visibility only — never
   *  touches `activeSnippetId` or any snippet data. */
  setSearch: (query: string) => void;
  /**
   * Set the library sort (spec §02 → Sort): re-selecting the **active** field
   * flips direction; choosing a **different** field switches to it and resets to
   * descending. Persisted by an orchestration subscriber; never touches the
   * active snippet. `order` may be passed to hydrate an exact stored state.
   */
  setSort: (by: SortBy, order?: SortOrder) => void;
  /**
   * Duplicate the active snippet (spec §02 → Duplicate): flushes the live buffer
   * into the source draft first so the copy reflects in-progress edits, then
   * prepends an independent copy ("(copy)" name, fresh identity/timestamps) and
   * makes it active. Returns the new id, or null if no snippet is active. `now`
   * injectable; `id` injectable for deterministic tests.
   */
  duplicateActiveSnippet: (now?: Date, id?: string) => string | null;
  /** Update the draft buffer only (no persistence; debounced commit follows). */
  updateDraft: (text: string) => void;
  /**
   * Replace the active snippet's draft spec with new text and reload the editor
   * on the draft view (bumps `bufferEpoch`). Used by programmatic rewrites such
   * as Extract-to-Dataset (spec §03F), which substitutes embedded data for a
   * by-name reference. No-op when no snippet is active. `now` injectable.
   */
  replaceActiveDraft: (text: string, now?: Date) => void;
  /**
   * Replace a snippet's spec wholesale and make it active — the Chart Builder's
   * **Save changes** (edit-in-place, spec §06 → Open in builder). Unlike the draft
   * rewrite above, this *republishes*: both `spec` and `draftSpec` become the built
   * text, so the edited snippet has no pending draft (the builder is the source).
   * An auto-named snippet re-derives its name from the new content (like `publish`);
   * a user-renamed one keeps its name. Flushes the outgoing active buffer first,
   * recomputes `datasetRefs`, and reloads the editor on the now-active snippet.
   * No-op (returns false) for an unknown id. `now` injectable.
   */
  replaceSnippetSpec: (id: string, specText: string, now?: Date) => boolean;
  /**
   * Persist the editor buffer into the active snippet's **draft**, if it parses
   * as JSON. Returns whether it committed (a half-typed, unparseable buffer is
   * skipped, per spec §03B). Never touches the published `spec`. `now` is
   * injectable for deterministic tests.
   */
  commitDraft: (now?: Date) => boolean;
  /** Switch the editor between the draft and published views (spec §03D). */
  setEditorView: (view: EditorView) => void;
  /**
   * Propagate a dataset rename across every referencing snippet: rewrite the
   * named-data references in both `spec` and `draftSpec`, recompute `datasetRefs`,
   * and refresh the live editor buffer if the active snippet was rewritten — so a
   * user mid-edit doesn't see their draft silently break (docs/architecture/07
   * §6). Returns the number of snippets changed. `now` injectable.
   */
  renameDatasetRefs: (oldName: string, newName: string, now?: Date) => number;
  /**
   * Promote the active snippet's current draft to its published version (spec
   * §03D → Publish). Flushes the live buffer first, then makes `spec` identical
   * to `draftSpec`. Returns whether a snippet was active. `now` injectable.
   */
  publish: (now?: Date) => boolean;
  /**
   * Discard the active snippet's draft, restoring it to the published version
   * (spec §03D → Revert), and reload the editor on the draft view. Returns
   * whether a snippet was active. `now` injectable.
   */
  revert: (now?: Date) => boolean;
  /** Reset to initial state (tests, future "new workspace"). */
  reset: () => void;
}

/** Newest-modified first — the library's default ordering (spec §02 → Sort). */
function byModifiedDesc(a: Snippet, b: Snippet): number {
  return b.modified.localeCompare(a.modified);
}

/** The id of the most-recently-modified snippet, or null if there are none. */
function newestId(snippets: Snippet[]): string | null {
  if (snippets.length === 0) return null;
  return [...snippets].sort(byModifiedDesc)[0].id;
}

function draftFor(snippets: Snippet[], id: string | null): string {
  return snippets.find((s) => s.id === id)?.draftSpec ?? '';
}

export const useSnippetStore = create<SnippetState>((set, get) => ({
  snippets: [],
  activeSnippetId: null,
  draftText: '',
  editorView: 'draft',
  bufferEpoch: 0,
  searchQuery: '',
  sortBy: DEFAULT_SORT_BY,
  sortOrder: DEFAULT_SORT_ORDER,

  hydrate: (snippets, activeId) => {
    const id = activeId !== undefined ? activeId : newestId(snippets);
    set((s) => ({
      snippets,
      activeSnippetId: id,
      draftText: draftFor(snippets, id),
      editorView: 'draft',
      bufferEpoch: s.bufferEpoch + 1,
    }));
  },

  addSnippets: (incoming) => {
    if (incoming.length === 0) return;
    set((s) => {
      const snippets = [...incoming, ...s.snippets];
      if (s.activeSnippetId !== null) return { snippets };
      const id = newestId(snippets);
      return {
        snippets,
        activeSnippetId: id,
        draftText: draftFor(snippets, id),
        editorView: 'draft',
        bufferEpoch: s.bufferEpoch + 1,
      };
    });
  },

  createSnippet: (options) => {
    get().commitDraft(); // flush the outgoing snippet's valid edits before switching away
    const created = createSnippet(options);
    // Mirror datasetRefs from the draft at creation, so a snippet built with a
    // named-data reference (Chart Builder, §06) is linked to its dataset immediately.
    // datasetRefs tracks the draft — the version being edited — at every mutation
    // (create/auto-save/extract/revert/publish), so the link reflects what the user
    // sees, not only the last publish (docs/architecture/07 §3). Inline-data specs
    // (the sample template) resolve to no refs. spec === draftSpec at creation.
    const snippet = { ...created, datasetRefs: recomputeDatasetRefs(created.draftSpec) };
    set((s) => ({
      snippets: [snippet, ...s.snippets],
      activeSnippetId: snippet.id,
      draftText: snippet.draftSpec,
      editorView: 'draft', // a fresh snippet always opens on its editable draft
      bufferEpoch: s.bufferEpoch + 1,
    }));
    return snippet.id;
  },

  selectSnippet: (id) => {
    if (id === get().activeSnippetId) return;
    // Flush the outgoing snippet's valid in-progress edits before switching, so
    // navigating away within the auto-save debounce window doesn't drop them
    // (spec §03B — auto-save preserves in-progress work). An unparseable buffer
    // is left uncommitted, exactly as the debounced auto-save would.
    get().commitDraft();
    set((s) => ({
      activeSnippetId: id,
      draftText: draftFor(s.snippets, id),
      editorView: 'draft',
      bufferEpoch: s.bufferEpoch + 1,
    }));
  },

  removeSnippet: (id) => {
    set((s) => {
      const snippets = s.snippets.filter((x) => x.id !== id);
      if (s.activeSnippetId !== id) return { snippets };
      // Deleting the active snippet falls back to the newest remaining one, so the
      // editor and detail panel stay populated (spec §02 → Delete); null only when
      // none remain.
      const activeSnippetId = newestId(snippets);
      return {
        snippets,
        activeSnippetId,
        draftText: draftFor(snippets, activeSnippetId),
        editorView: 'draft',
        bufferEpoch: s.bufferEpoch + 1, // the buffer was reloaded for the new active snippet
      };
    });
  },

  renameSnippet: (id, name, now) => {
    set((s) => {
      const target = s.snippets.find((x) => x.id === id);
      if (!target || target.name === name) return s; // unknown id or no change
      const modified = (now ?? new Date()).toISOString();
      return {
        // An explicit rename freezes the name (`nameSource: 'user'`) — publish's
        // content-derived naming only ever rewrites auto-picked names (spec §02
        // → Naming & Tags).
        snippets: s.snippets.map((x) =>
          x.id === id ? { ...x, name, nameSource: 'user' as const, modified } : x,
        ),
      };
    });
  },

  setComment: (id, comment, now) => {
    set((s) => {
      const target = s.snippets.find((x) => x.id === id);
      if (!target || target.comment === comment) return s; // unknown id or no change
      const modified = (now ?? new Date()).toISOString();
      return {
        snippets: s.snippets.map((x) => (x.id === id ? { ...x, comment, modified } : x)),
      };
    });
  },

  setSearch: (searchQuery) => set({ searchQuery }),

  setSort: (by, order) => {
    set((s) => {
      // An explicit order hydrates a stored state directly (orchestration init).
      if (order !== undefined) return { sortBy: by, sortOrder: order };
      // Re-selecting the active field flips direction; a different field resets
      // to descending (spec §02 → Sort).
      if (by === s.sortBy) {
        return { sortOrder: s.sortOrder === 'desc' ? 'asc' : 'desc' };
      }
      return { sortBy: by, sortOrder: 'desc' };
    });
  },

  duplicateActiveSnippet: (now, id) => {
    // Flush the live buffer into the source draft first, so the copy faithfully
    // mirrors what the user currently sees, not the last auto-saved draft.
    get().commitDraft(now);
    const { activeSnippetId, snippets } = get();
    if (!activeSnippetId) return null;
    const source = snippets.find((s) => s.id === activeSnippetId);
    if (!source) return null;

    const copy = duplicateSnippetRecord(source, { now, id });
    set((s) => ({
      snippets: [copy, ...s.snippets],
      activeSnippetId: copy.id,
      draftText: copy.draftSpec,
      editorView: 'draft', // a fresh copy opens on its editable draft
      bufferEpoch: s.bufferEpoch + 1,
    }));
    return copy.id;
  },

  updateDraft: (draftText) => set({ draftText }),

  replaceActiveDraft: (text, now) => {
    const { activeSnippetId } = get();
    if (!activeSnippetId) return;
    const modified = (now ?? new Date()).toISOString();
    const datasetRefs = recomputeDatasetRefs(text);
    set((s) => ({
      snippets: s.snippets.map((x) =>
        // Recompute datasetRefs from the new draft so a programmatic rewrite that
        // introduces a by-name reference — Extract-to-Dataset (spec §03F) — links
        // the dataset to the snippet immediately, not only on the next publish.
        x.id === activeSnippetId ? { ...x, draftSpec: text, datasetRefs, modified } : x,
      ),
      draftText: text,
      editorView: 'draft',
      bufferEpoch: s.bufferEpoch + 1,
    }));
  },

  replaceSnippetSpec: (id, specText, now) => {
    // Flush the outgoing active snippet's valid in-progress edits before we switch
    // the active snippet (mirrors createSnippet / selectSnippet).
    get().commitDraft(now);
    const target = get().snippets.find((s) => s.id === id);
    if (!target) return false;
    const modified = (now ?? new Date()).toISOString();
    const datasetRefs = recomputeDatasetRefs(specText);
    set((s) => ({
      snippets: s.snippets.map((x) =>
        x.id === id
          ? {
              ...x,
              // Auto names track content (as publish does); a user-chosen name stays.
              ...(isAutoNamed(x)
                ? { name: deriveSnippetName(specText) ?? x.name, nameSource: 'auto' as const }
                : {}),
              // Republish: the built spec becomes both versions, so there is no
              // pending draft to reconcile after a builder save.
              spec: specText,
              draftSpec: specText,
              datasetRefs,
              modified,
            }
          : x,
      ),
      activeSnippetId: id,
      draftText: specText,
      editorView: 'draft',
      bufferEpoch: s.bufferEpoch + 1,
    }));
    return true;
  },

  commitDraft: (now) => {
    const { activeSnippetId, draftText, snippets } = get();
    if (!activeSnippetId) return false;

    try {
      JSON.parse(draftText);
    } catch {
      return false; // half-typed spec — skip, retry after the next pause
    }

    const active = snippets.find((s) => s.id === activeSnippetId);
    // Auto-save writes the DRAFT only — never the published spec (spec §03B/§03D).
    if (!active || active.draftSpec === draftText) {
      return false; // nothing changed in the draft
    }

    const modified = (now ?? new Date()).toISOString();
    // Recompute datasetRefs from the just-committed draft so a hand-typed by-name
    // reference links to its dataset as soon as auto-save fires. This runs on the
    // debounced auto-save and only on valid JSON (the parse guard above), so it is
    // not "every keystroke" and never sees a transiently-invalid draft — it keeps
    // datasetRefs mirroring the draft the user is editing (docs/architecture/07 §3).
    const datasetRefs = recomputeDatasetRefs(draftText);
    set({
      snippets: snippets.map((s) =>
        s.id === activeSnippetId ? { ...s, draftSpec: draftText, datasetRefs, modified } : s,
      ),
    });
    return true;
  },

  setEditorView: (editorView) => set({ editorView }),

  renameDatasetRefs: (oldName, newName, now) => {
    if (oldName === newName) return 0;
    const lower = oldName.toLowerCase();
    const { snippets, activeSnippetId, editorView } = get();
    let updated = 0;
    let activeDraftAfter: string | null = null;

    // Whether a spec text references the old name (case-insensitive). Reads the
    // content rather than reserializing, so a non-referencing snippet's text is
    // left byte-for-byte intact (no spurious reformat/modified bump).
    const referencesOld = (text: string): boolean =>
      extractDatasetRefs(text).some((r) => r.toLowerCase() === lower);

    const next = snippets.map((s) => {
      // Consult both spec and draft, not only datasetRefs: datasetRefs now mirrors
      // the DRAFT, so a name still referenced only by the published spec (the user
      // removed it from the draft but hasn't published) would otherwise be missed
      // and the published spec would rot to a now-renamed dataset (arch 07 §6).
      if (!referencesOld(s.spec) && !referencesOld(s.draftSpec)) return s;
      updated++;
      const spec = renameDatasetInSpec(s.spec, oldName, newName);
      const draftSpec = renameDatasetInSpec(s.draftSpec, oldName, newName);
      if (s.id === activeSnippetId) activeDraftAfter = draftSpec;
      return {
        ...s,
        spec,
        draftSpec,
        datasetRefs: recomputeDatasetRefs(draftSpec),
        modified: (now ?? new Date()).toISOString(),
      };
    });

    if (updated === 0) return 0;
    set((st) => ({
      snippets: next,
      // If the active snippet's draft was rewritten, reload the editor buffer so
      // the live (draft-view) buffer reflects the new name and the next auto-save
      // doesn't clobber the rewrite with the stale old name.
      ...(activeDraftAfter !== null && editorView === 'draft'
        ? { draftText: activeDraftAfter, bufferEpoch: st.bufferEpoch + 1 }
        : activeDraftAfter !== null
          ? { bufferEpoch: st.bufferEpoch + 1 }
          : {}),
    }));
    return updated;
  },

  publish: (now) => {
    // Flush the live buffer into the draft first, so Publish promotes exactly
    // what the user sees (an invalid buffer leaves the last valid draft in place).
    get().commitDraft(now);
    const { activeSnippetId, snippets } = get();
    if (!activeSnippetId) return false;

    const modified = (now ?? new Date()).toISOString();
    set({
      snippets: snippets.map((s) =>
        s.id === activeSnippetId
          ? // Promote the draft and recompute datasetRefs from the now-published
            // spec, so the bidirectional snippet↔dataset link mirrors reality
            // (spec §03D, docs/architecture/07 §3). An auto-picked name keeps
            // tracking the published content — title, else mark + encodings —
            // and stays auto so the next publish tracks again; a user-chosen
            // name (`nameSource: 'user'`) is never rewritten (spec §03D →
            // Publish, §02 → Naming & Tags).
            {
              ...s,
              ...(isAutoNamed(s)
                ? { name: deriveSnippetName(s.draftSpec) ?? s.name, nameSource: 'auto' as const }
                : {}),
              spec: s.draftSpec,
              datasetRefs: recomputeDatasetRefs(s.draftSpec),
              modified,
            }
          : s,
      ),
    });
    return true;
  },

  revert: (now) => {
    const { activeSnippetId, snippets } = get();
    if (!activeSnippetId) return false;

    const active = snippets.find((s) => s.id === activeSnippetId);
    if (!active) return false;

    const modified = (now ?? new Date()).toISOString();
    // The draft is restored to the published spec, so datasetRefs must mirror it
    // again — drop any link that existed only in the discarded draft (e.g. an
    // Extract-to-Dataset rewrite the user reverted before publishing).
    const datasetRefs = recomputeDatasetRefs(active.spec);
    set((s) => ({
      snippets: s.snippets.map((x) =>
        x.id === activeSnippetId ? { ...x, draftSpec: x.spec, datasetRefs, modified } : x,
      ),
      // Reload the editor with the restored draft, on the editable view (spec §03D).
      draftText: active.spec,
      editorView: 'draft',
      bufferEpoch: s.bufferEpoch + 1,
    }));
    return true;
  },

  reset: () =>
    set({
      snippets: [],
      activeSnippetId: null,
      draftText: '',
      editorView: 'draft',
      bufferEpoch: 0,
      searchQuery: '',
      sortBy: DEFAULT_SORT_BY,
      sortOrder: DEFAULT_SORT_ORDER,
    }),
}));

/** Selector: the active snippet record, or null. Derive — never store. */
export const selectActiveSnippet = (s: SnippetState): Snippet | null =>
  s.snippets.find((x) => x.id === s.activeSnippetId) ?? null;

/**
 * Selector: the text the editor shows and the preview renders. The draft view
 * shows the live editable buffer; the published view shows the stored published
 * spec (spec §03D — "the preview always reflects the version in the editor").
 */
export const selectShownText = (s: SnippetState): string => {
  if (s.editorView === 'published') {
    return selectActiveSnippet(s)?.spec ?? '';
  }
  return s.draftText;
};
