/**
 * Custom chart theme library + Theme Builder state (scope doc §4.4; spec §04).
 *
 * Holds the durable collection of saved custom themes plus the Theme Builder's
 * working draft: which theme is open, its in-progress name and config text, the
 * last config that parsed (what the builder's gallery renders), and the current
 * validation message.
 *
 * Same two-layer shape as DatasetStore:
 *   - Low-level mutators (`add`/`update`/`remove`) are the single place the
 *     `themes` array changes; the persistence subscriber writes them through to
 *     IndexedDB.
 *   - Draft orchestration (`select`/`createTheme`/`updateDraft`/`applyDraftFont`/
 *     `saveDraft`) keeps the modal component thin and the logic testable.
 *
 * 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 { applyFontToConfig, createCustomTheme, type CustomTheme } from '@core/custom-theme';
import { isNameTaken, makeUniqueName } from '@core/naming';
import { isJsonObject, type JsonObject } from '@core/spec-config';
import { type ConfigPath, setConfigValue } from '@core/theme-controls';
import { customThemeSelection } from '@core/vega-themes';
import { useAppStore } from './AppStore';

/** The Theme Builder's in-progress edit of the selected theme. */
interface ThemeDraft {
  name: string;
  /** The config as editable JSON text (pretty-printed on load). */
  configText: string;
}

export interface CustomThemeState {
  themes: CustomTheme[];
  /** The theme open in the builder, or null (empty builder state). */
  selectedId: number | null;
  /** The in-progress edit, or null when nothing is selected. */
  draft: ThemeDraft | null;
  /**
   * The last draft config that parsed — what the builder's gallery renders.
   * Editing through invalid JSON keeps the previous valid config on screen.
   */
  draftConfig: JsonObject | null;
  /** Live JSON parse error for the draft config text, or null when it parses. */
  parseError: string | null;
  /** Save-time validation message (name missing/taken), or null. */
  saveError: string | null;

  /** Replace the library from storage. */
  hydrate: (themes: CustomTheme[]) => void;
  /** Open a theme in the builder (or clear with null). Resets the draft to the saved record. */
  select: (id: number | null) => void;
  /**
   * Create a new theme seeded with `config` and open it. The name is made
   * unique automatically (non-interactive path — never blocks on a collision).
   */
  createTheme: (baseName: string, config: JsonObject, now?: Date) => CustomTheme;
  /** Patch the draft. A config-text change re-parses (gallery follows valid states). */
  updateDraft: (patch: Partial<ThemeDraft>) => void;
  /**
   * Apply a font family across the draft config (top-level `font` + every
   * explicit font slot) and reformat the text. No-op with a parse error when
   * the current text is invalid JSON — fix the JSON first.
   */
  applyDraftFont: (family: string) => void;
  /**
   * Apply a pure transform to the draft config and reflect it in both the
   * parsed config (the gallery follows) and the reformatted text. The single
   * write path for every structured control. No-op with a parse error — the
   * controls are disabled while the JSON is invalid; fix the JSON first.
   */
  mutateDraftConfig: (fn: (config: JsonObject) => JsonObject) => void;
  /**
   * Validate and commit the draft to the selected theme. On failure sets
   * `saveError`/`parseError` and returns false.
   */
  saveDraft: (now?: Date) => boolean;

  /** Low-level: add a fully-formed theme and select it. Returns the record with its assigned id. */
  add: (theme: CustomTheme) => CustomTheme;
  /**
   * Low-level batch add (import path): reassign ids from the store's id
   * authority and append, WITHOUT touching the builder selection — an import
   * must not hijack an open Theme Builder draft.
   */
  addThemes: (incoming: CustomTheme[]) => void;
  /** Low-level: merge a patch into a theme, advancing `modified`. */
  update: (id: number, patch: Partial<CustomTheme>, now?: Date) => void;
  /**
   * Remove a theme. If it was the active chart-theme selection, the selection
   * falls back to the house style (the same fallback rendering applies).
   */
  remove: (id: number) => void;

  /** Reset to initial state (tests). */
  reset: () => void;
}

/** Next free numeric id — one past the max (same contract as `nextDatasetId`). */
function nextThemeId(themes: ReadonlyArray<CustomTheme>): number {
  return themes.reduce((max, t) => Math.max(max, t.id), 0) + 1;
}

/** A theme's config as the builder's editable text. */
function configToText(config: JsonObject): string {
  return JSON.stringify(config, null, 2);
}

/** Parse draft text into a config object, or an error message. */
function parseConfigText(text: string): { config: JsonObject } | { error: string } {
  const trimmed = text.trim();
  if (trimmed === '') return { config: {} };
  try {
    const parsed: unknown = JSON.parse(trimmed);
    if (!isJsonObject(parsed)) return { error: 'The config must be a JSON object, like {...}.' };
    return { config: parsed };
  } catch (e) {
    return { error: `Invalid JSON: ${(e as Error).message}` };
  }
}

export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
  themes: [],
  selectedId: null,
  draft: null,
  draftConfig: null,
  parseError: null,
  saveError: null,

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

  select: (id) => {
    if (id === null) {
      set({ selectedId: null, draft: null, draftConfig: null, parseError: null, saveError: null });
      return;
    }
    const theme = get().themes.find((t) => t.id === id);
    if (!theme) return;
    set({
      selectedId: id,
      draft: { name: theme.name, configText: configToText(theme.config) },
      draftConfig: theme.config,
      parseError: null,
      saveError: null,
    });
  },

  createTheme: (baseName, config, now) => {
    const name = makeUniqueName(
      baseName,
      get().themes.map((t) => t.name),
    );
    const theme = get().add(createCustomTheme({ name, config, now }));
    // `add` selected the new id; open it into the draft.
    get().select(theme.id);
    return theme;
  },

  updateDraft: (patch) => {
    const draft = get().draft;
    if (!draft) return;
    const next = { ...draft, ...patch };
    if (patch.configText !== undefined && patch.configText !== draft.configText) {
      const parsed = parseConfigText(next.configText);
      if ('error' in parsed) {
        set({ draft: next, parseError: parsed.error, saveError: null });
      } else {
        set({ draft: next, draftConfig: parsed.config, parseError: null, saveError: null });
      }
      return;
    }
    set({ draft: next, saveError: null });
  },

  applyDraftFont: (family) =>
    get().mutateDraftConfig((config) => applyFontToConfig(config, family)),

  mutateDraftConfig: (fn) => {
    const draft = get().draft;
    if (!draft) return;
    const parsed = parseConfigText(draft.configText);
    if ('error' in parsed) {
      set({ parseError: parsed.error });
      return;
    }
    const config = fn(parsed.config);
    set({
      draft: { ...draft, configText: configToText(config) },
      draftConfig: config,
      parseError: null,
      saveError: null,
    });
  },

  saveDraft: (now) => {
    const { draft, selectedId, themes } = get();
    if (!draft || selectedId === null) return false;

    const name = draft.name.trim();
    if (name === '') {
      set({ saveError: 'Enter a theme name.' });
      return false;
    }
    if (isNameTaken(name, themes, selectedId)) {
      set({ saveError: `A theme named "${name}" already exists. Choose a different name.` });
      return false;
    }
    const parsed = parseConfigText(draft.configText);
    if ('error' in parsed) {
      set({ parseError: parsed.error });
      return false;
    }

    get().update(selectedId, { name, config: parsed.config }, now);
    // Re-seed the draft from the committed record so the baseline is clean.
    get().select(selectedId);
    return true;
  },

  add: (theme) => {
    const withId = { ...theme, id: nextThemeId(get().themes) };
    set((s) => ({ themes: [...s.themes, withId], selectedId: withId.id }));
    return withId;
  },

  addThemes: (incoming) => {
    if (incoming.length === 0) return;
    set((s) => {
      let nextId = nextThemeId(s.themes);
      const withIds = incoming.map((t) => ({ ...t, id: nextId++ }));
      return { themes: [...s.themes, ...withIds] };
    });
  },

  update: (id, patch, now) => {
    const modified = patch.modified ?? (now ?? new Date()).toISOString();
    set((s) => ({
      themes: s.themes.map((t) => (t.id === id ? { ...t, ...patch, modified } : t)),
    }));
  },

  remove: (id) => {
    set((s) => ({
      themes: s.themes.filter((t) => t.id !== id),
      ...(s.selectedId === id
        ? { selectedId: null, draft: null, draftConfig: null, parseError: null, saveError: null }
        : {}),
    }));
    // The picker selection can't point at a deleted record; fall back to the
    // house style explicitly so the persisted preference stays meaningful.
    const app = useAppStore.getState();
    if (app.chartTheme === customThemeSelection(id)) app.setChartTheme('astrolabe');
  },

  reset: () =>
    set({
      themes: [],
      selectedId: null,
      draft: null,
      draftConfig: null,
      parseError: null,
      saveError: null,
    }),
}));

/**
 * The write half every Theme Builder structured-control panel shares: a
 * `set(path, value)` that mutates the draft config through `mutateDraftConfig`,
 * deleting the leaf (and pruning emptied ancestors) when `value` is `undefined`.
 * Panels bind their controls to this, so the read (`getConfigValue` + the `as*`
 * coercions) and write live in one vocabulary. Lives here, beside
 * `mutateDraftConfig`, rather than in the JSX field module — the field components
 * must stay component-only for fast refresh.
 */
export function useConfigSetter(): (path: ConfigPath, value: unknown) => void {
  const mutate = useCustomThemeStore((s) => s.mutateDraftConfig);
  return (path, value) => mutate((c) => setConfigValue(c, path, value));
}

/** Selector: the saved record the builder has open, or null. Derive — never store. */
export const selectSelectedTheme = (s: CustomThemeState): CustomTheme | null =>
  s.themes.find((t) => t.id === s.selectedId) ?? null;

/**
 * Selector: whether the draft differs from its saved record — drives the Save
 * button and the modal's unsaved-change snapshot. Text-level comparison against
 * the pretty-printed saved config: `select`/`saveDraft` seed the draft from
 * exactly that text, so an untouched draft is never dirty.
 */
export const selectIsDraftDirty = (s: CustomThemeState): boolean => {
  const theme = selectSelectedTheme(s);
  if (!theme || !s.draft) return false;
  return s.draft.name !== theme.name || s.draft.configText !== configToText(theme.config);
};
