import { customAlphabet } from 'nanoid';
import type { Settings, ZDocument, ZElement } from './types';

const letters = customAlphabet('abcdefghijklmnopqrstuvwxyz', 1);
const alnum = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 7);

/** 8 chars, letter-first so it is always a valid CSS class. */
export function newId(): string {
  return letters() + alnum();
}

export function createElement(type: string, settings: Settings = {}, children: ZElement[] = []): ZElement {
  return { id: newId(), type, settings: { ...settings }, children };
}

export function emptyDocument(): ZDocument {
  return { version: 1, elements: [] };
}

export interface Located {
  element: ZElement;
  parent: ZElement | null;
  index: number;
  /** Ancestors from root to direct parent. */
  path: ZElement[];
}

/** Depth-first walk. Return false from the visitor to stop. */
export function walk(elements: ZElement[], visit: (el: ZElement, parent: ZElement | null, index: number, depth: number) => void | false, parent: ZElement | null = null, depth = 0): boolean {
  for (let i = 0; i < elements.length; i++) {
    const el = elements[i];
    if (visit(el, parent, i, depth) === false) return false;
    if (el.children?.length && walk(el.children, visit, el, depth + 1) === false) return false;
  }
  return true;
}

export function findById(doc: ZDocument, id: string): Located | null {
  let found: Located | null = null;
  const path: ZElement[] = [];
  const rec = (list: ZElement[], parent: ZElement | null): boolean => {
    for (let i = 0; i < list.length; i++) {
      const el = list[i];
      if (el.id === id) {
        found = { element: el, parent, index: i, path: [...path] };
        return true;
      }
      if (el.children?.length) {
        path.push(el);
        if (rec(el.children, el)) return true;
        path.pop();
      }
    }
    return false;
  };
  rec(doc.elements, null);
  return found;
}

export function siblingsOf(doc: ZDocument, parentId: string | null): ZElement[] | null {
  if (parentId === null) return doc.elements;
  const located = findById(doc, parentId);
  return located ? located.element.children : null;
}

/** Inserts (mutates). Returns the inserted element or null if parent is missing. */
export function insertElement(doc: ZDocument, element: ZElement, parentId: string | null, index?: number): ZElement | null {
  const list = siblingsOf(doc, parentId);
  if (!list) return null;
  const i = index === undefined || index < 0 || index > list.length ? list.length : index;
  list.splice(i, 0, element);
  return element;
}

/** Removes (mutates). Returns { element, parentId, index } or null. */
export function removeElement(doc: ZDocument, id: string): { element: ZElement; parentId: string | null; index: number } | null {
  const located = findById(doc, id);
  if (!located) return null;
  const list = located.parent ? located.parent.children : doc.elements;
  list.splice(located.index, 1);
  return { element: located.element, parentId: located.parent?.id ?? null, index: located.index };
}

/**
 * Moves an element (mutates). Semantics: the element is removed first, then inserted at `index` in the
 * destination list *as it is after the removal*. This makes the operation trivially invertible:
 * moving back to the original (parentId, index) restores the exact previous position.
 */
export function moveElement(doc: ZDocument, id: string, parentId: string | null, index: number): boolean {
  if (parentId === id || isDescendant(doc, parentId, id)) return false;
  const located = findById(doc, id);
  if (!located) return false;
  const fromList = located.parent ? located.parent.children : doc.elements;
  const toList = siblingsOf(doc, parentId);
  if (!toList) return false;
  fromList.splice(located.index, 1);
  const target = Math.max(0, Math.min(index, toList.length));
  toList.splice(target, 0, located.element);
  return true;
}

/**
 * Converts a "drop before sibling at visual index" into the index expected by moveElement when the
 * element is moved within the same list (its removal shifts later indexes by one).
 */
export function adjustedMoveIndex(doc: ZDocument, id: string, parentId: string | null, index: number): number {
  const located = findById(doc, id);
  if (!located) return index;
  const sameList = (located.parent?.id ?? null) === parentId;
  return sameList && located.index < index ? index - 1 : index;
}

/** True when `candidateId` is inside the subtree of `ancestorId`. */
export function isDescendant(doc: ZDocument, candidateId: string | null, ancestorId: string): boolean {
  if (candidateId === null) return false;
  const ancestor = findById(doc, ancestorId);
  if (!ancestor) return false;
  let found = false;
  walk(ancestor.element.children, (el) => {
    if (el.id === candidateId) {
      found = true;
      return false;
    }
  });
  return found;
}

/** Deep clone with fresh ids for the whole subtree. Also rewrites repeater item ids. */
export function cloneElement(element: ZElement): ZElement {
  const copy: ZElement = structuredClone(element);
  walk([copy], (el) => {
    el.id = newId();
    for (const [k, v] of Object.entries(el.settings)) {
      if (Array.isArray(v) && v.length && typeof v[0] === 'object' && v[0] && '_id' in (v[0] as object)) {
        el.settings[k] = (v as Array<Record<string, unknown>>).map((item) => ({ ...item, _id: newId() }));
      }
    }
  });
  return copy;
}

/**
 * Deep copy of a list of elements with fresh ids for every element and every repeater item `_id` (used when
 * a template / kit / imported page is inserted). New ids never collide with `taken` (the ids already in the
 * document) nor with each other. Missing `settings` / `children` are filled in.
 */
export function reassignIds(elements: ZElement[], taken: Iterable<string> = []): ZElement[] {
  const used = new Set(taken);
  const fresh = (): string => {
    let id = newId();
    while (used.has(id)) id = newId();
    used.add(id);
    return id;
  };
  let copy: ZElement[];
  try {
    copy = structuredClone(elements);
  } catch {
    copy = JSON.parse(JSON.stringify(elements)) as ZElement[];
  }
  const rec = (list: ZElement[]): ZElement[] =>
    list
      .filter((el) => el && typeof el === 'object' && typeof el.type === 'string')
      .map((el) => {
        el.id = fresh();
        el.settings = el.settings && typeof el.settings === 'object' ? el.settings : {};
        for (const [k, v] of Object.entries(el.settings)) {
          if (Array.isArray(v) && v.some((item) => item && typeof item === 'object' && '_id' in (item as object))) {
            el.settings[k] = v.map((item) => (item && typeof item === 'object' && '_id' in (item as object) ? { ...(item as object), _id: fresh() } : item));
          }
        }
        el.children = Array.isArray(el.children) ? rec(el.children) : [];
        return el;
      });
  return rec(copy);
}

export function countElements(doc: ZDocument): number {
  let n = 0;
  walk(doc.elements, () => {
    n++;
  });
  return n;
}

export function flatten(doc: ZDocument): ZElement[] {
  const out: ZElement[] = [];
  walk(doc.elements, (el) => {
    out.push(el);
  });
  return out;
}

/** Validates the rough shape of a document coming from storage / clipboard. */
export function isDocument(value: unknown): value is ZDocument {
  return Boolean(value) && typeof value === 'object' && Array.isArray((value as ZDocument).elements);
}

export function isElement(value: unknown): value is ZElement {
  const v = value as ZElement;
  return Boolean(v) && typeof v === 'object' && typeof v.id === 'string' && typeof v.type === 'string' && typeof v.settings === 'object' && Array.isArray(v.children);
}

/** Normalizes a document loaded from storage: ensures children arrays, settings objects, valid ids. */
export function normalizeDocument(input: unknown): ZDocument {
  const doc: ZDocument = isDocument(input) ? (input as ZDocument) : emptyDocument();
  const seen = new Set<string>();
  const fix = (list: unknown[]): ZElement[] =>
    list
      .filter((el) => el && typeof el === 'object' && typeof (el as ZElement).type === 'string')
      .map((raw) => {
        const el = raw as ZElement;
        let id = typeof el.id === 'string' && /^[a-z][a-z0-9]{2,15}$/.test(el.id) ? el.id : newId();
        while (seen.has(id)) id = newId();
        seen.add(id);
        return {
          ...el,
          id,
          settings: el.settings && typeof el.settings === 'object' ? el.settings : {},
          children: Array.isArray(el.children) ? fix(el.children) : [],
        };
      });
  return { version: 1, elements: fix(doc.elements) };
}
