import type { DocumentBlock, DocumentState } from "../block/document.js"; import { commitBlock, enterSourceMode, exitSourceMode, insertBlock as insertBlockAt, removeBlock as removeBlockAt, replaceMarkdown, serializeState, setEditable, setSourceText, stateFromMarkdown, } from "../block/document.js"; import { resolveHandler } from "../block/registry.js"; import type { TableData } from "../block/table.handler.js"; // Importing the editor barrel registers the builtin editors as a side effect. import "../editor/mod.js"; import { resolveEditor } from "../editor/registry.js"; import { SHADOW_STYLES } from "./shadow.style.js"; /** * `` — markdown document controller. * * Shadow DOM holds the rendered block tree and styles. * Light DOM holds the `
` source (SSR-friendly, formatter-safe).
 *
 * Children are plain `
` elements, not custom elements. * Each div stores `data-md-raw` and `data-md-attrs` (JSON). * * When `editable` attribute is present, enables click-to-edit, insert handles, * and block actions. */ export class EmkomaDocumentElement extends HTMLElement { static formAssociated = true; private readonly shadow: ShadowRoot; private readonly internals: ElementInternals; private readonly blockPopupWrapper = new WeakMap(); private _editingDiv: HTMLElement | null = null; /** * Single source of truth. The shadow tree and the light-DOM `
` are both
   * projections of this — never read document content back out of them.
   */
  private _state: DocumentState = {
    blocks: [],
    sourceMode: false,
    sourceText: "",
  };
  private _originalRaw: string | null = null;
  private _formDataHandler: ((e: FormDataEvent) => void) | null = null;
  private _docMouseDownHandler: ((e: MouseEvent) => void) | null = null;
  private _shadowHandlersBound = false;

  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: "open" });
    this.internals = this.attachInternals();
  }

  /** Raw markdown source, from whichever representation is currently live. */
  get raw(): string {
    return serializeState(this._state);
  }

  set raw(value: string) {
    this._state = replaceMarkdown(this._state, value);
    this.syncSource();
    this.render();
  }

  /** Read the initial markdown out of the light DOM. Only used to seed state. */
  private readSource(): string {
    const pre = this.querySelector(":scope > pre");
    return (pre ? pre.textContent : this.textContent) ?? "";
  }

  /**
   * Write markdown into the light DOM `
` without re-parsing.
   * Creates the `
` when absent so programmatically created elements
   * (no SSR light DOM) are still writable.
   */
  private writeSource(value: string): void {
    let pre = this.querySelector(":scope > pre");
    if (!pre) {
      pre = document.createElement("pre");
      this.appendChild(pre);
    }
    pre.textContent = value;
  }

  /** Project current state into the light DOM `
` and the form value. */
  private syncSource(): void {
    const markdown = serializeState(this._state);
    this.writeSource(markdown);
    this.internals.setFormValue(markdown);
  }

  get editable(): boolean {
    return this.hasAttribute("editable");
  }

  get blocked(): boolean {
    return this.hasAttribute("blocked");
  }

  /** Serialize the document back to markdown. */
  serialize(): string {
    return serializeState(this._state);
  }

  connectedCallback(): void {
    // Seed state from the light DOM once; from here on state is authoritative.
    if (this._state.blocks.length === 0 && !this._state.sourceMode) {
      this._state = stateFromMarkdown(this.readSource());
    }
    this._originalRaw ??= this.readSource();
    this.internals.setFormValue(this.raw);
    this.render();

    if (this.editable) {
      this.setupEditableHandlers();
    }

    const form = this.internals.form;
    if (form) {
      this._formDataHandler = (e: FormDataEvent) => {
        for (const el of this.shadow.querySelectorAll<
          HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
        >("input[name], select[name], textarea[name]")) {
          if (el.disabled) continue;
          if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) {
            if (el.checked) e.formData.append(el.name, el.value);
            continue;
          }
          if (el instanceof HTMLSelectElement && el.multiple) {
            for (const opt of el.selectedOptions) e.formData.append(el.name, opt.value);
            continue;
          }
          if (el instanceof HTMLInputElement && el.type === "file") {
            const files = el.files;
            if (files && files.length > 0) {
              for (const file of files) e.formData.append(el.name, file);
            } else {
              e.formData.append(el.name, new File([], "", { type: "application/octet-stream" }));
            }
            continue;
          }
          e.formData.append(el.name, el.value);
        }
      };
      form.addEventListener("formdata", this._formDataHandler);
    }
  }

  disconnectedCallback(): void {
    if (this._docMouseDownHandler) {
      document.removeEventListener("mousedown", this._docMouseDownHandler);
      this._docMouseDownHandler = null;
    }
    if (this._formDataHandler) {
      this.internals.form?.removeEventListener("formdata", this._formDataHandler);
      this._formDataHandler = null;
    }
  }

  formResetCallback(): void {
    if (this._originalRaw !== null) {
      this._state = stateFromMarkdown(this._originalRaw);
      this.syncSource();
      this.render();
    }
  }

  formDisabledCallback(disabled: boolean): void {
    if (disabled) {
      this.removeAttribute("editable");
    }
  }

  /** Re-parse content with current handlers. Call after registering custom block handlers. */
  hydrate(): void {
    // Re-identify from the current document rather than the original markup,
    // so edits made before a handler was registered are preserved.
    this._state = stateFromMarkdown(serializeState(this._state));
    this.render();
    if (this.editable) {
      this.setupEditableHandlers();
    }
  }

  static get observedAttributes(): string[] {
    return ["editable", "blocked"];
  }

  attributeChangedCallback(name: string): void {
    if (name === "editable" || name === "blocked") {
      // Source mode needs an editable, unblocked document; setEditable()
      // demotes it (re-parsing the edited text) rather than stranding it.
      this._state = setEditable(this._state, this.editable && !this.blocked);
      this.render();
      if (this.editable) {
        this.setupEditableHandlers();
      }
    }
  }

  /** Project `_state` into the shadow tree. Never reads content back out. */
  private render(): void {
    this._editingDiv = null;
    this.shadow.replaceChildren();

    const style = document.createElement("style");
    style.textContent = SHADOW_STYLES;
    this.shadow.appendChild(style);

    // Source editor — always present in editable mode, hidden until toggled
    if (this.editable && !this.blocked) {
      this.shadow.appendChild(this.createDocToolbar());
    }

    // Blocks container
    const blocks = document.createElement("div");
    blocks.classList.add("blocks");
    if (this._state.sourceMode) blocks.classList.add("hidden");

    const chunks = this._state.blocks;

    if (this.editable && !this.blocked) {
      blocks.appendChild(this.createInsertHandle(0));
    }

    for (let i = 0; i < chunks.length; i++) {
      const div = this.createBlockDiv(chunks[i]);
      // Index into `_state.blocks`, so edits can be routed back to the model.
      div.dataset.blockIndex = String(i);
      const wrapper = this.editable ? this.createBlockWrapper(div) : this.createSimpleWrapper(div);
      blocks.appendChild(wrapper);
      if (this.editable && !this.blocked) {
        blocks.appendChild(this.createInsertHandle(i + 1));
      }
    }
    this.shadow.appendChild(blocks);

    // Source textarea
    if (this.editable && !this.blocked) {
      const source = document.createElement("textarea");
      source.classList.add("source-editor");
      if (!this._state.sourceMode) source.classList.add("hidden");
      source.value = this._state.sourceMode ? this._state.sourceText : "";
      // Keep the model current on every keystroke, so no other transition
      // has to reach into the DOM to find out what the user typed.
      source.addEventListener("input", () => {
        this._state = setSourceText(this._state, source.value);
        this.syncSource();
      });
      this.shadow.appendChild(source);
      if (this._state.sourceMode) {
        source.focus();
      }
    }
  }

  // --- Document toolbar ---

  private createDocToolbar(): HTMLElement {
    const toolbar = document.createElement("div");
    toolbar.classList.add("doc-toolbar");

    const visualBtn = document.createElement("button");
    visualBtn.textContent = "Visual";
    if (!this._state.sourceMode) visualBtn.classList.add("active");
    visualBtn.addEventListener("click", () => {
      this._state = exitSourceMode(this._state);
      this.syncSource();
      this.render();
    });

    const sourceBtn = document.createElement("button");
    sourceBtn.textContent = "Source";
    if (this._state.sourceMode) sourceBtn.classList.add("active");
    sourceBtn.addEventListener("click", () => {
      // enterSourceMode() seeds the text from the current blocks, so block
      // edits carry across instead of being replaced by the pre-edit source.
      this._state = enterSourceMode(this._state);
      this.syncSource();
      this.render();
    });

    toolbar.appendChild(visualBtn);
    toolbar.appendChild(sourceBtn);
    return toolbar;
  }

  // --- Block div creation ---

  private createBlockDiv(chunk: DocumentBlock): HTMLElement {
    const div = document.createElement("div");
    div.dataset.blockType = chunk.type;
    div.dataset.mdRaw = chunk.raw;
    if (chunk.attrs && Object.keys(chunk.attrs).length > 0) {
      div.dataset.mdAttrs = JSON.stringify(chunk.attrs);
    }

    const handler = resolveHandler(chunk.type);
    if (handler) {
      div.innerHTML = handler.render(chunk.raw, chunk.attrs);
    }

    return div;
  }

  private createSimpleWrapper(div: HTMLElement): HTMLElement {
    const wrapper = document.createElement("div");
    wrapper.classList.add("block-wrapper");
    wrapper.appendChild(div);
    return wrapper;
  }

  // --- Editable mode ---

  private createBlockWrapper(div: HTMLElement): HTMLElement {
    const wrapper = document.createElement("div");
    wrapper.classList.add("block-wrapper");
    wrapper.appendChild(div);

    wrapper.addEventListener("click", (e) => {
      const target = e.target as HTMLElement;
      if (target.closest("a")) return;
      if (target.closest("button")) return;
      if (target.closest("textarea")) return;
      if (target.closest("input")) return;
      if (wrapper.classList.contains("editing")) return;
      this.enterBlockEdit(div, wrapper);
    });

    return wrapper;
  }

  private showBlockPopup(wrapper: HTMLElement, content?: string): HTMLElement {
    this.dismissBlockPopup(wrapper);

    const popup = document.createElement("div");
    popup.classList.add("block-popup");
    popup.setAttribute("popover", "manual");

    if (content) {
      const msg = document.createElement("span");
      msg.classList.add("block-popup-msg");
      msg.textContent = content;
      popup.appendChild(msg);
    }

    if (!this.blocked) {
      const delBtn = document.createElement("button");
      delBtn.classList.add("danger");
      delBtn.textContent = "Delete";
      delBtn.addEventListener("click", (e) => {
        e.stopPropagation();
        this.removeBlock(wrapper);
      });
      popup.appendChild(delBtn);
    }

    // CSS anchor positioning: wrapper is the anchor, popup targets it
    const anchorName = `--ek-editing-block`;
    wrapper.style.setProperty("anchor-name", anchorName);
    popup.style.setProperty("position-anchor", anchorName);

    // Append to shadow root so the popover is in the correct scope
    this.shadow.appendChild(popup);
    popup.showPopover();

    if (!supportsAnchorPositioning()) {
      placePopupWithoutAnchor(popup, wrapper);
    }

    // Track wrapper ref on the popup for cleanup
    this.blockPopupWrapper.set(popup, wrapper);

    return popup;
  }

  private dismissBlockPopup(wrapper: HTMLElement): void {
    for (const el of this.shadow.querySelectorAll(".block-popup[popover]")) {
      if (this.blockPopupWrapper.get(el) === wrapper) {
        try {
          el.hidePopover();
        } catch {
          /* already hidden */
        }
        el.remove();
      }
    }
    wrapper.style.removeProperty("anchor-name");
    wrapper.classList.remove("editing");
  }

  private enterBlockEdit(div: HTMLElement, wrapper: HTMLElement): void {
    // Commit previous edit if different block
    if (this._editingDiv && this._editingDiv !== div) {
      this.commitBlockEdit(this._editingDiv);
    }

    const type = div.dataset.blockType!;
    const raw = div.dataset.mdRaw ?? "";
    const attrs = parseDataAttrs(div);
    const handler = resolveHandler(type);

    if (!handler) return;

    this._editingDiv = div;

    const cleanup = () => {
      this.dismissBlockPopup(wrapper);
      if (this._editingDiv === div) this._editingDiv = null;
    };

    const onCommit = (newRaw: string, newAttrs?: Record) => {
      div.dataset.mdRaw = newRaw;
      if (newAttrs) {
        div.dataset.mdAttrs = JSON.stringify(newAttrs);
      }
      const updatedAttrs = parseDataAttrs(div);
      // Route the edit through the model first, then patch just this block's
      // markup — a full re-render here would destroy the caret.
      this._state = commitBlock(this._state, Number(div.dataset.blockIndex), newRaw, updatedAttrs);
      this.syncSource();
      div.innerHTML = handler.render(newRaw, updatedAttrs);
      cleanup();
    };

    const onCancel = () => {
      div.innerHTML = handler.render(raw, attrs);
      cleanup();
    };

    const editor = resolveEditor(type);
    if (editor) {
      const popup = this.showBlockPopup(wrapper);
      editor(div, raw, attrs, onCommit, onCancel, popup);
    } else if (type.startsWith("widget:")) {
      const popup = this.showBlockPopup(wrapper, "Widget — configured in code.");
      const okBtn = document.createElement("button");
      okBtn.textContent = "OK";
      okBtn.addEventListener("click", onCancel);
      popup.insertBefore(okBtn, popup.querySelector(".danger"));
    } else {
      const popup = this.showBlockPopup(wrapper, "Editing not supported yet.");
      const okBtn = document.createElement("button");
      okBtn.textContent = "OK";
      okBtn.addEventListener("click", onCancel);
      popup.insertBefore(okBtn, popup.querySelector(".danger"));
    }

    // Last: every branch above opens a popup, and opening one dismisses the
    // previous popup for this wrapper — which clears "editing". Marking the
    // wrapper before that point loses the flag, and a wrapper that never
    // looks "editing" is re-entered on every click, rebuilding the editor
    // under the caret and swallowing the dblclick that reaches text mode.
    wrapper.classList.add("editing");
  }

  private commitBlockEdit(div: HTMLElement): void {
    const wrapper = div.closest(".block-wrapper") as HTMLElement | null;
    const type = div.dataset.blockType!;
    const raw = div.dataset.mdRaw ?? "";
    const attrs = parseDataAttrs(div);
    const handler = resolveHandler(type);
    if (handler) {
      div.innerHTML = handler.render(raw, attrs);
    }
    if (wrapper) this.dismissBlockPopup(wrapper);
    if (this._editingDiv === div) this._editingDiv = null;
  }

  private setupEditableHandlers(): void {
    // Called from connectedCallback, hydrate() and attributeChangedCallback,
    // so both groups are guarded against duplicate registration. The shadow
    // root survives re-parses and reconnects, so its listeners bind once ever.
    if (this._shadowHandlersBound) {
      this.bindOutsideMouseDown();
      return;
    }
    this._shadowHandlersBound = true;

    // Hover → show insert handles
    this.shadow.addEventListener("mouseover", (e) => {
      const wrapper = (e.target as HTMLElement).closest?.(".block-wrapper") as HTMLElement | null;
      if (!wrapper) return;
      this.showAdjacentHandles(wrapper);
    });

    this.shadow.addEventListener("mouseout", (e) => {
      const wrapper = (e.target as HTMLElement).closest?.(".block-wrapper") as HTMLElement | null;
      if (!wrapper) return;
      setTimeout(() => this.hideAdjacentHandles(wrapper), 120);
    });

    this.bindOutsideMouseDown();
  }

  /** Click outside → commit active edit. Removed again on disconnect. */
  private bindOutsideMouseDown(): void {
    if (this._docMouseDownHandler) return;
    this._docMouseDownHandler = (e: MouseEvent) => {
      if (this._editingDiv && !this.contains(e.target as Node)) {
        this.commitBlockEdit(this._editingDiv);
      }
    };
    document.addEventListener("mousedown", this._docMouseDownHandler);
  }

  // --- Insert handles ---

  private createInsertHandle(index: number): HTMLElement {
    const handle = document.createElement("div");
    handle.classList.add("insert-handle");
    handle.dataset.insertAt = String(index);

    const btn = document.createElement("button");
    btn.classList.add("insert-btn");
    btn.textContent = "+";
    btn.setAttribute("aria-label", `Insert a block at position ${index + 1}`);
    btn.addEventListener("click", (e) => {
      e.stopPropagation();
      this.showInsertMenu(handle);
    });

    handle.appendChild(btn);
    handle.addEventListener("mouseenter", () => handle.classList.add("visible"));
    handle.addEventListener("mouseleave", () => {
      if (!handle.querySelector(".insert-menu")) {
        handle.classList.remove("visible");
      }
    });

    return handle;
  }

  private showInsertMenu(handle: HTMLElement): void {
    this.shadow.querySelector(".insert-menu")?.remove();

    const menu = document.createElement("div");
    menu.classList.add("insert-menu");

    const options: [string, string][] = [
      ["Paragraph", "paragraph"],
      ["Heading", "heading"],
      ["List", "list"],
      ["Task list", "task-list"],
      ["Table", "table"],
      ["Image", "image"],
      ["Quote", "quote"],
      ["Code", "code"],
      ["Divider", "divider"],
    ];

    handle.appendChild(menu);

    const wrappers = this.shadow.querySelectorAll(".block-wrapper");
    for (const w of wrappers) {
      (w as HTMLElement).style.pointerEvents = "none";
    }

    const restorePointerEvents = () => {
      for (const w of wrappers) {
        (w as HTMLElement).style.pointerEvents = "";
      }
    };

    const onClose = (e: Event) => {
      if (!menu.contains(e.target as Node)) {
        menu.remove();
        handle.classList.remove("visible");
        restorePointerEvents();
        this.shadow.removeEventListener("mousedown", onClose);
      }
    };
    setTimeout(() => this.shadow.addEventListener("mousedown", onClose), 0);

    for (const [label, type] of options) {
      const btn = document.createElement("button");
      btn.textContent = label;
      btn.addEventListener("click", (e) => {
        e.stopPropagation();
        menu.remove();
        handle.classList.remove("visible");
        this.shadow.removeEventListener("mousedown", onClose);
        this.insertBlock(handle, type);
        setTimeout(restorePointerEvents, 0);
      });
      menu.appendChild(btn);
    }
  }

  private insertBlock(handle: HTMLElement, type: string): void {
    const chunk = createDefaultChunk(type);
    const index = Number(handle.dataset.insertAt ?? this._state.blocks.length);

    // Mutate the model and re-project. Splicing the DOM directly would leave
    // every later block's index pointing at the wrong model entry.
    this._state = insertBlockAt(this._state, index, {
      type: chunk.type,
      raw: chunk.raw,
      attrs: chunk.attrs ?? {},
    });
    this.syncSource();
    this.render();

    if (type !== "divider" && resolveEditor(chunk.type)) {
      const div = this.blockDivAt(index);
      const wrapper = div?.closest(".block-wrapper") as HTMLElement | null;
      if (div && wrapper) {
        setTimeout(() => this.enterBlockEdit(div, wrapper), 50);
      }
    }
  }

  private removeBlock(wrapper: HTMLElement): void {
    const div = wrapper.querySelector("[data-block-index]") as HTMLElement | null;
    if (!div) return;
    this._state = removeBlockAt(this._state, Number(div.dataset.blockIndex));
    this.syncSource();
    this.render();
  }

  /** Find the rendered div for a model index. */
  private blockDivAt(index: number): HTMLElement | null {
    return this.shadow.querySelector(`[data-block-index="${index}"]`);
  }

  // --- Handle visibility ---

  private showAdjacentHandles(wrapper: HTMLElement): void {
    const prev = wrapper.previousElementSibling as HTMLElement | null;
    const next = wrapper.nextElementSibling as HTMLElement | null;
    if (prev?.classList.contains("insert-handle")) {
      prev.classList.add("visible");
    }
    if (next?.classList.contains("insert-handle")) {
      next.classList.add("visible");
    }
  }

  private hideAdjacentHandles(wrapper: HTMLElement): void {
    const prev = wrapper.previousElementSibling as HTMLElement | null;
    const next = wrapper.nextElementSibling as HTMLElement | null;
    setTimeout(() => {
      if (prev?.classList.contains("insert-handle") && !prev.matches(":hover")) {
        prev.classList.remove("visible");
      }
      if (next?.classList.contains("insert-handle") && !next.matches(":hover")) {
        next.classList.remove("visible");
      }
    }, 100);
  }
}

function supportsAnchorPositioning(): boolean {
  return (
    typeof CSS !== "undefined" &&
    typeof CSS.supports === "function" &&
    CSS.supports("anchor-name", "--probe") &&
    CSS.supports("bottom", "anchor(top)")
  );
}

function placePopupWithoutAnchor(popup: HTMLElement, wrapper: HTMLElement): void {
  const box = wrapper.getBoundingClientRect();
  popup.style.setProperty("bottom", `${Math.round(window.innerHeight - box.top)}px`);
  popup.style.setProperty("left", `${Math.round(box.left)}px`);
  popup.style.setProperty("width", `${Math.round(box.width)}px`);
}

/** Safely parse data-md-attrs from a block div. */
function parseDataAttrs(div: HTMLElement): Record {
  const raw = div.dataset.mdAttrs;
  if (!raw) return {};
  try {
    return JSON.parse(raw);
  } catch {
    return {};
  }
}

// -- Default chunks for insert menu ------------------------------------------

function createDefaultChunk(type: string): DocumentBlock {
  switch (type) {
    case "heading":
      return { type: "heading", raw: "New heading", attrs: { level: "2" } };
    case "list":
      return {
        type: "list",
        raw: "Item 1",
        attrs: { "list-type": "unordered" },
      };
    case "task-list":
      return { type: "task-list", raw: "[ ] Task 1", attrs: {} };
    case "table": {
      const data: TableData = {
        head: ["Column 1", "Column 2"],
        body: [
          ["", ""],
          ["", ""],
        ],
      };
      return { type: "table", raw: JSON.stringify(data, null, 2), attrs: {} };
    }
    case "image":
      return { type: "image", raw: "Alt text", attrs: { src: "" } };
    case "quote":
      return { type: "blockquote", raw: "Quote text", attrs: {} };
    case "code":
      return { type: "code-block", raw: "", attrs: {} };
    case "divider":
      return { type: "hr", raw: "", attrs: {} };
    default:
      return { type: "paragraph", raw: "", attrs: {} };
  }
}