import { defineStore } from 'pinia';
import { toRaw } from 'vue';
import type { BreakpointKey, ElementState, RevisionInfo, Settings, ZDocument, ZElement } from '../core/types';
import {
  adjustedMoveIndex,
  cloneElement,
  emptyDocument,
  insertElement,
  moveElement,
  normalizeDocument,
  removeElement,
  walk,
  type Located,
} from '../core/document';
import { History, invert, type Entry, type HistorySnapshot, type Op } from '../core/history';
import { instancesCss, lookupComponent } from '../core/components';
import { applyDefaults, makeKey } from '../core/settings';
import { buildColumn, buildSection, columnSettings, getStructure, sectionRowSettings } from '../core/structures';
import { controlListFor } from '../core/widgets';
import { generateElementCss, generateSettingsCss, generateTreeCss } from '../core/css';
import { pageControlsById } from '../core/pageControls';
import { controlsFor } from '../core/widgets';
import { ApiError, getApi, isSessionExpired } from '../api/client';
import { useGlobalStore } from './global';
import { useAccessStore } from './access';

/** Non-reactive history instance; `historyVersion` in state mirrors it for reactivity. */
const history = new History({ limit: 200, coalesceMs: 600 });

/**
 * Editing scope (components edit-in-place, T168/T210): the tree, page settings, dirty flag and history of the
 * document being edited. `enterScope()` swaps the page out for a component and returns the page's scope;
 * `restoreScope()` puts it back. While a component is edited `save()` goes to the override set with
 * `setSaveOverride()` (the component save), never to the page.
 */
export interface EditScope {
  doc: ZDocument;
  pageSettings: Settings;
  dirty: boolean;
  history: HistorySnapshot;
}

let saveOverride: ((status?: string) => Promise<boolean>) | null = null;

export function setSaveOverride(fn: ((status?: string) => Promise<boolean>) | null): void {
  saveOverride = fn;
}

/* ---- id -> Located index --------------------------------------------------------------------
 * `findById` walks the whole tree (O(n)). The editor calls `find`/`get` constantly (every control,
 * overlay frame, navigator row), so the store keeps a lazily rebuilt Map. It is invalidated by
 * structural ops (insert/remove/move) and by `load()`; settings/props edits keep it valid. Callers
 * read `historyVersion` so Vue computeds depending on `find()` refresh after every commit. */
interface IndexCache {
  map: Map<string, Located>;
  docRaw: ZDocument;
  dirty: boolean;
}
let indexCache: IndexCache | null = null;

/** Test helper: number of index rebuilds is observable through this counter. */
export const __indexStats = { rebuilds: 0 };

function buildIndex(doc: ZDocument): Map<string, Located> {
  __indexStats.rebuilds++;
  const map = new Map<string, Located>();
  const rec = (list: ZElement[], parent: ZElement | null, path: ZElement[]): void => {
    for (let i = 0; i < list.length; i++) {
      const el = list[i];
      map.set(el.id, { element: el, parent, index: i, path });
      if (el.children?.length) rec(el.children, el, [...path, el]);
    }
  };
  rec(doc.elements, null, []);
  return map;
}

function indexFor(doc: ZDocument): Map<string, Located> {
  const raw = toRaw(doc);
  if (!indexCache || indexCache.dirty || indexCache.docRaw !== raw) {
    indexCache = { map: buildIndex(doc), docRaw: raw, dirty: false };
  }
  return indexCache.map;
}

/** Cheap O(1) sanity check: a hit whose parent list no longer holds it at `index` means the tree was mutated behind the store's back. */
function lookup(doc: ZDocument, id: string): Located | null {
  const hit = indexFor(doc).get(id);
  if (!hit) return null;
  const list = hit.parent ? hit.parent.children : doc.elements;
  if (list[hit.index] !== hit.element) {
    invalidateIndex();
    return indexFor(doc).get(id) ?? null;
  }
  return hit;
}

function invalidateIndex(): void {
  if (indexCache) indexCache.dirty = true;
}

export function rawClone<T>(value: T): T {
  try {
    return structuredClone(toRaw(value));
  } catch {
    return JSON.parse(JSON.stringify(value)) as T;
  }
}

export interface UpdateOptions {
  /** Merge with the previous entry when it touched the same element within a short time (typing). */
  coalesce?: boolean;
  label?: string;
  bp?: BreakpointKey;
  state?: ElementState;
}

export const useDocumentStore = defineStore('document', {
  state: () => ({
    doc: emptyDocument() as ZDocument,
    postId: 0,
    title: '',
    status: 'draft',
    dirty: false,
    saving: false,
    saveError: '',
    /** 'session_expired' when the last save failed because the login/nonce is no longer valid, else the API error code. */
    saveErrorCode: '',
    lastSavedAt: null as string | null,
    revisions: [] as RevisionInfo[],
    historyVersion: 0,
    /**
     * Page-level settings (scroll snap ...), flat with the usual key encoding. Persisted as
     * `_zadex_page_settings = { settings, css }` where `css` is generated by `pageCss()`.
     */
    pageSettings: {} as Settings,
  }),

  getters: {
    canUndo: (s) => s.historyVersion >= 0 && history.canUndo,
    canRedo: (s) => s.historyVersion >= 0 && history.canRedo,
    // Copies: the History stacks are mutated in place, and a computed that returns the same array
    // reference would not notify the History panel after undo/redo (Vue 3.4+ skips unchanged values).
    historyEntries: (s): readonly Entry[] => (s.historyVersion >= 0 ? [...history.entries] : []),
    redoEntries: (s): readonly Entry[] => (s.historyVersion >= 0 ? [...history.redoEntries] : []),
    /** Element count, served from the id index (cached until the next structural change). */
    count: (s) => {
      void s.historyVersion; // re-evaluate after commits/undo/redo/load
      return indexFor(s.doc as ZDocument).size;
    },
  },

  actions: {
    load(doc: ZDocument, meta: { postId: number; title?: string; status?: string } = { postId: 0 }): void {
      this.doc = normalizeDocument(rawClone(doc));
      this.postId = meta.postId;
      this.title = meta.title ?? this.title;
      this.status = meta.status ?? this.status;
      this.dirty = false;
      history.clear();
      this.historyVersion = history.version;
      invalidateIndex();
    },

    /** O(1) lookup through the id index. Reading `historyVersion` keeps callers' computeds in sync. */
    find(id: string): Located | null {
      void this.historyVersion;
      return lookup(this.doc, id);
    },

    get(id: string): ZElement | null {
      return this.find(id)?.element ?? null;
    },

    parentOf(id: string): ZElement | null {
      return this.find(id)?.parent ?? null;
    },

    /** Applies an op to the document without recording it. */
    applyOp(op: Op): void {
      if (op.type === 'insert' || op.type === 'remove' || op.type === 'move') invalidateIndex();
      switch (op.type) {
        case 'settings': {
          const el = this.get(op.id);
          if (!el) return;
          const keys = new Set([...Object.keys(op.before), ...Object.keys(op.after)]);
          for (const k of keys) {
            const v = op.after[k];
            if (v === undefined) delete el.settings[k];
            else el.settings[k] = v;
          }
          break;
        }
        case 'props': {
          const el = this.get(op.id);
          if (!el) return;
          const keys = new Set([...Object.keys(op.before), ...Object.keys(op.after)]);
          for (const k of keys) {
            const v = (op.after as Record<string, unknown>)[k];
            if (v === undefined) delete (el as unknown as Record<string, unknown>)[k];
            else (el as unknown as Record<string, unknown>)[k] = v;
          }
          break;
        }
        case 'page': {
          const keys = new Set([...Object.keys(op.before), ...Object.keys(op.after)]);
          for (const k of keys) {
            const v = op.after[k];
            if (v === undefined) delete this.pageSettings[k];
            else this.pageSettings[k] = v;
          }
          break;
        }
        case 'insert':
          insertElement(this.doc, op.element, op.parentId, op.index);
          break;
        case 'remove':
          removeElement(this.doc, op.element.id);
          break;
        case 'move':
          moveElement(this.doc, op.id, op.to.parentId, op.to.index);
          break;
      }
    },

    commit(ops: Op[], label: string, coalesceKey?: string): void {
      if (!ops.length) return;
      // Role manager (T170): content-only users may only commit content settings of unlocked elements.
      if (!useAccessStore().allows(ops, (id) => this.get(id))) return;
      for (const op of ops) this.applyOp(op);
      history.push({ ops, label, coalesceKey });
      this.historyVersion = history.version;
      this.dirty = true;
    },

    /** Sets several raw settings keys at once. `undefined` removes a key. */
    updateSettings(id: string, patch: Settings, opts: UpdateOptions = {}): void {
      const el = this.get(id);
      if (!el) return;
      const before: Settings = {};
      const after: Settings = {};
      let changed = false;
      for (const [k, v] of Object.entries(patch)) {
        const prev = el.settings[k];
        if (JSON.stringify(prev) === JSON.stringify(v)) continue;
        before[k] = prev === undefined ? undefined : rawClone(prev);
        after[k] = v === undefined ? undefined : rawClone(v);
        changed = true;
      }
      if (!changed) return;
      const key = opts.coalesce ? `settings:${id}:${Object.keys(patch).join(',')}` : undefined;
      this.commit([{ type: 'settings', id, before, after }], opts.label ?? 'Edit', key);
    },

    /** Sets one control value at a breakpoint/state. */
    setSetting(id: string, controlId: string, value: unknown, opts: UpdateOptions = {}): void {
      const key = makeKey(controlId, { bp: opts.bp, state: opts.state });
      this.updateSettings(id, { [key]: value }, opts);
    },

    updateProps(id: string, patch: Partial<Pick<ZElement, 'label' | 'hidden' | 'locked' | 'classes' | 'designLocked'>>, label = 'Edit element'): void {
      const el = this.get(id);
      if (!el) return;
      const before: Partial<ZElement> = {};
      for (const k of Object.keys(patch) as Array<keyof typeof patch>) {
        (before as Record<string, unknown>)[k] = el[k] === undefined ? undefined : rawClone(el[k]);
      }
      this.commit([{ type: 'props', id, before, after: rawClone(patch) }], label);
    },

    /* ---- page settings ---- */
    /**
     * Accepts the persisted `{ settings, css }` envelope (or a bare settings object). Not undoable.
     * `overrides` are keys WordPress owns outside the page settings meta (e.g. `page_template`).
     */
    loadPageSettings(input: unknown, overrides: Settings = {}): void {
      const raw = input && typeof input === 'object' ? (input as Record<string, unknown>) : {};
      const inner = raw.settings && typeof raw.settings === 'object' ? (raw.settings as Settings) : 'css' in raw ? {} : (raw as Settings);
      this.pageSettings = { ...rawClone(inner), ...overrides };
    },

    /** Sets page-level keys (already encoded with breakpoint/state). `undefined` removes. Undoable. */
    updatePageSettings(patch: Settings, label = 'Page settings'): void {
      const before: Settings = {};
      const after: Settings = {};
      let changed = false;
      for (const [k, v] of Object.entries(patch)) {
        const prev = this.pageSettings[k];
        if (JSON.stringify(prev) === JSON.stringify(v)) continue;
        before[k] = prev === undefined ? undefined : rawClone(prev);
        after[k] = v === undefined ? undefined : rawClone(v);
        changed = true;
      }
      if (changed) this.commit([{ type: 'page', before, after }], label);
    },

    /** CSS for the page settings, scoped to this document's wrapper (`.zadex-<postId>`). */
    pageCss(): string {
      const global = useGlobalStore();
      return generateSettingsCss(this.pageSettings, { controls: pageControlsById(), breakpoints: global.breakpoints, wrapper: `.zadex-${this.postId}` });
    },

    /** Number of elements that carry a global class. */
    classUsage(classId: string): number {
      let n = 0;
      walk(this.doc.elements, (el) => {
        if (el.classes?.includes(classId)) n++;
      });
      return n;
    },

    /** Removes a global class from every element (one undoable entry). Returns how many elements changed. */
    stripClass(classId: string, label = 'Remove class'): number {
      const ops: Op[] = [];
      walk(this.doc.elements, (el) => {
        if (!el.classes?.includes(classId)) return;
        const after = el.classes.filter((c) => c !== classId);
        ops.push({ type: 'props', id: el.id, before: { classes: rawClone(el.classes) }, after: { classes: after.length ? after : undefined } });
      });
      this.commit(ops, label);
      return ops.length;
    },

    insert(element: ZElement, parentId: string | null, index?: number, label = 'Add element'): ZElement {
      const list = parentId === null ? this.doc.elements : this.get(parentId)?.children;
      const at = index === undefined || index < 0 ? (list?.length ?? 0) : index;
      const raw = rawClone(element);
      this.commit([{ type: 'insert', parentId, index: at, element: raw }], label);
      return this.get(raw.id) ?? raw;
    },

    remove(id: string, label = 'Delete element'): void {
      const located = this.find(id);
      if (!located) return;
      this.commit([{ type: 'remove', parentId: located.parent?.id ?? null, index: located.index, element: rawClone(located.element) }], label);
    },

    /**
     * Moves an element. `index` is the visual insertion index in the destination list *before* the
     * element is removed (what a drop indicator naturally produces).
     */
    move(id: string, parentId: string | null, index: number, label = 'Move element'): boolean {
      const located = this.find(id);
      if (!located) return false;
      const from = { parentId: located.parent?.id ?? null, index: located.index };
      const to = { parentId, index: adjustedMoveIndex(this.doc, id, parentId, index) };
      if (from.parentId === to.parentId && from.index === to.index) return false;
      this.commit([{ type: 'move', id, from, to }], label);
      return true;
    },

    duplicate(id: string): ZElement | null {
      const located = this.find(id);
      if (!located) return null;
      const copy = cloneElement(rawClone(located.element));
      this.commit([{ type: 'insert', parentId: located.parent?.id ?? null, index: located.index + 1, element: copy }], 'Duplicate');
      return this.get(copy.id);
    },

    undo(): Entry | undefined {
      const entry = history.undo();
      if (entry) {
        for (let i = entry.ops.length - 1; i >= 0; i--) this.applyOp(invert(entry.ops[i]));
        this.historyVersion = history.version;
        this.dirty = true;
      }
      return entry;
    },

    redo(): Entry | undefined {
      const entry = history.redo();
      if (entry) {
        for (const op of entry.ops) this.applyOp(op);
        this.historyVersion = history.version;
        this.dirty = true;
      }
      return entry;
    },

    breakCoalescing(): void {
      history.breakCoalescing();
    },

    elementCss(id: string): string {
      const el = this.get(id);
      if (!el) return '';
      const global = useGlobalStore();
      return generateElementCss(el, { controls: controlsFor(el.type), breakpoints: global.breakpoints });
    },

    fullCss(): string {
      const global = useGlobalStore();
      const tree = generateTreeCss(this.doc.elements, controlsFor, global.breakpoints);
      // Component instances (T168/T210): their own settings + style overrides, scoped to the instance.
      const instances = instancesCss(this.doc.elements, lookupComponent, controlsFor, global.breakpoints);
      return instances ? [tree, instances].filter(Boolean).join('\n') : tree;
    },

    /** Swaps the edited document for another scope (component edit-in-place). Returns the previous scope. */
    enterScope(next: { doc: ZDocument; pageSettings?: Settings }): EditScope {
      const prev: EditScope = { doc: this.doc, pageSettings: this.pageSettings, dirty: this.dirty, history: history.snapshot() };
      this.doc = normalizeDocument(rawClone(next.doc));
      this.pageSettings = rawClone(next.pageSettings ?? {});
      this.dirty = false;
      history.restore(null);
      this.historyVersion = history.version;
      invalidateIndex();
      return prev;
    },

    /** Puts back a scope returned by enterScope(). */
    restoreScope(scope: EditScope): void {
      this.doc = scope.doc;
      this.pageSettings = scope.pageSettings;
      this.dirty = scope.dirty;
      history.restore(scope.history);
      this.historyVersion = history.version;
      invalidateIndex();
    },

    async save(status?: string): Promise<boolean> {
      if (saveOverride) return saveOverride(status);
      if (this.saving) return false;
      this.saving = true;
      this.saveError = '';
      this.saveErrorCode = '';
      try {
        const api = await getApi();
        const template = this.pageSettings.page_template;
        const res = await api.saveDocument(this.postId, {
          data: rawClone(this.doc),
          css: this.fullCss(),
          pageSettings: { settings: rawClone(this.pageSettings), css: this.pageCss() },
          status,
          // Page layout goes to _wp_page_template (only once the editor knows about it).
          ...(typeof template === 'string' ? { template } : {}),
        });
        this.status = res.status || this.status;
        this.revisions = res.revisions ?? this.revisions;
        this.lastSavedAt = res.modified || new Date().toISOString();
        this.dirty = false;
        return true;
      } catch (e) {
        this.saveError = e instanceof Error ? e.message : String(e);
        this.saveErrorCode = isSessionExpired(e) ? 'session_expired' : e instanceof ApiError ? e.code : '';
        return false;
      } finally {
        this.saving = false;
      }
    },

    async loadRevisions(): Promise<void> {
      const api = await getApi();
      this.revisions = await api.listRevisions(this.postId);
    },

    /** Loads a revision into the editor as an unsaved change (undoable). */
    async applyRevision(rev: number): Promise<void> {
      const api = await getApi();
      const { data } = await api.getRevision(this.postId, rev);
      const next = normalizeDocument(data);
      const ops: Op[] = [];
      for (let i = this.doc.elements.length - 1; i >= 0; i--) {
        ops.push({ type: 'remove', parentId: null, index: i, element: rawClone(this.doc.elements[i]) });
      }
      next.elements.forEach((el, i) => ops.push({ type: 'insert', parentId: null, index: i, element: el }));
      this.commit(ops, 'Restore revision');
    },

    /**
     * Inserts a new section (container, optionally split into column containers) at `index` of the root
     * (or of `parentId`). Manifest defaults are applied so the section behaves like a hand-added one.
     */
    insertSection(index: number, presetId: string, parentId: string | null = null): ZElement | null {
      const preset = getStructure(presetId);
      if (!preset) return null;
      const defaults = applyDefaults({}, controlListFor('container').filter((c) => !c.id.startsWith('_')));
      const section = buildSection(preset, defaults, defaults);
      return this.insert(section, parentId, index, `Add section (${preset.label})`);
    },

    /**
     * Re-splits an existing container into the preset's columns, keeping every child: leaf widgets are
     * wrapped into the first column, surplus columns are merged into the last kept one, missing columns
     * are added. One undo step.
     */
    applyStructure(id: string, presetId: string): boolean {
      const preset = getStructure(presetId);
      const el = this.get(id);
      if (!preset || !el) return false;
      if (!useAccessStore().canStructure) {
        useAccessStore().deny();
        return false;
      }
      const ops: Op[] = [];
      const run = (op: Op): void => {
        this.applyOp(op);
        ops.push(op);
      };
      const setSettings = (targetId: string, patch: Settings): void => {
        const target = this.get(targetId);
        if (!target) return;
        const before: Settings = {};
        const after: Settings = {};
        for (const [k, v] of Object.entries(patch)) {
          if (JSON.stringify(target.settings[k]) === JSON.stringify(v)) continue;
          before[k] = target.settings[k] === undefined ? undefined : rawClone(target.settings[k]);
          after[k] = rawClone(v);
        }
        if (Object.keys(after).length) run({ type: 'settings', id: targetId, before, after });
      };
      const defaults = applyDefaults({}, controlListFor('container').filter((c) => !c.id.startsWith('_')));
      const wantColumns = preset.columns.length > 0;

      // 1. Leaf widgets (and mixed content) go into a fresh first column when the preset has columns.
      const leaves = el.children.filter((c) => c.type !== 'container');
      if (wantColumns && leaves.length) {
        const wrap = buildColumn(preset.columns[0], defaults);
        run({ type: 'insert', parentId: id, index: 0, element: wrap });
        leaves.forEach((leaf, k) => {
          const located = this.find(leaf.id);
          if (!located) return;
          run({ type: 'move', id: leaf.id, from: { parentId: id, index: located.index }, to: { parentId: wrap.id, index: k } });
        });
      }

      const current = () => this.get(id)?.children ?? [];
      if (wantColumns) {
        const n = preset.columns.length;
        // 2. Too many columns: merge the surplus into the last kept column, then remove them.
        while (current().length > n) {
          const cols = current();
          const extra = cols[cols.length - 1];
          const keep = cols[n - 1];
          for (const child of [...extra.children]) {
            const located = this.find(child.id);
            if (!located) continue;
            run({ type: 'move', id: child.id, from: { parentId: extra.id, index: located.index }, to: { parentId: keep.id, index: keep.children.length } });
          }
          const located = this.find(extra.id);
          if (!located) break;
          run({ type: 'remove', parentId: id, index: located.index, element: rawClone(located.element) });
        }
        // 3. Too few: append empty columns.
        while (current().length < n) {
          const col = buildColumn(preset.columns[current().length], defaults);
          run({ type: 'insert', parentId: id, index: current().length, element: col });
        }
        // 4. Widths + row direction.
        current().forEach((col, i) => setSettings(col.id, columnSettings(preset.columns[i])));
        setSettings(id, sectionRowSettings(true));
      } else {
        // "1 column": flatten column containers back into the section, keeping order.
        const cols = current().filter((c) => c.type === 'container');
        for (const col of cols) {
          const colIndex = this.find(col.id)?.index ?? 0;
          let at = colIndex;
          for (const child of [...col.children]) {
            const located = this.find(child.id);
            if (!located) continue;
            run({ type: 'move', id: child.id, from: { parentId: col.id, index: located.index }, to: { parentId: id, index: at++ } });
          }
          const located = this.find(col.id);
          if (located) run({ type: 'remove', parentId: id, index: located.index, element: rawClone(located.element) });
        }
        setSettings(id, sectionRowSettings(false));
      }

      if (!ops.length) return false;
      history.push({ ops, label: `Structure: ${preset.label}` });
      this.historyVersion = history.version;
      this.dirty = true;
      return true;
    },

    replaceDocument(doc: ZDocument, label = 'Replace content'): void {
      const next = normalizeDocument(rawClone(doc));
      const ops: Op[] = [];
      for (let i = this.doc.elements.length - 1; i >= 0; i--) {
        ops.push({ type: 'remove', parentId: null, index: i, element: rawClone(this.doc.elements[i]) });
      }
      next.elements.forEach((el, i) => ops.push({ type: 'insert', parentId: null, index: i, element: el }));
      this.commit(ops, label);
    },
  },
});

export type DocumentStore = ReturnType<typeof useDocumentStore>;
