import { html, render } from "/js/lib/lit-html.js";
import { Component } from "/js/components/component.js";
import { getUnresolvedFacetsFromText } from "/js/facetHelpers.js";
import { avatarTemplate } from "/js/templates/avatar.template.js";
import { getDisplayName } from "/js/dataHelpers.js";
import { getIndexFromByteIndex, getByteLength } from "/js/utils.js";
import { TYPEAHEAD_SERVICE_URL } from "/js/config.js";

const FACET_TYPES = new Set([
  "app.bsky.richtext.facet#mention",
  "app.bsky.richtext.facet#link",
  "app.bsky.richtext.facet#tag",
]);

function parseUriList(uriList) {
  return uriList
    .split(/\r?\n/)
    .filter((line) => line.length > 0 && !line.startsWith("#"))
    .join("\n");
}

function facetsOverlap(a, b) {
  return (
    a.index.byteStart < b.index.byteEnd && a.index.byteEnd > b.index.byteStart
  );
}

function editableLineTemplate(lineText, lineFacets, lineByteOffset) {
  if (lineText.length === 0) return html`<div><br /></div>`;
  const parts = [];
  let cursor = 0;
  for (const facet of lineFacets) {
    const startChar = getIndexFromByteIndex(
      lineText,
      facet.index.byteStart - lineByteOffset,
    );
    const endChar = getIndexFromByteIndex(
      lineText,
      facet.index.byteEnd - lineByteOffset,
    );
    if (startChar < cursor) continue;
    if (cursor < startChar) parts.push(lineText.slice(cursor, startChar));
    parts.push(
      html`<span class="facet" data-testid="facet"
        >${lineText.slice(startChar, endChar)}</span
      >`,
    );
    cursor = endChar;
  }
  if (cursor < lineText.length) parts.push(lineText.slice(cursor));
  return html`<div>${parts}</div>`;
}

function editableContentTemplate(text, facets) {
  const valid = facets
    .filter((f) => FACET_TYPES.has(f.features[0]?.$type))
    .sort((a, b) => a.index.byteStart - b.index.byteStart);
  const distinct = [];
  for (const facet of valid) {
    if (!distinct.some((d) => facetsOverlap(d, facet))) distinct.push(facet);
  }

  const lines = text.split("\n");
  const divs = [];
  let byteOffset = 0;
  for (const line of lines) {
    const lineByteLength = getByteLength(line);
    const lineFacets = distinct.filter(
      (facet) =>
        facet.index.byteStart >= byteOffset &&
        facet.index.byteEnd <= byteOffset + lineByteLength,
    );
    divs.push(editableLineTemplate(line, lineFacets, byteOffset));
    byteOffset += lineByteLength + 1;
  }
  return html`${divs}`;
}

function findNodeAtCharOffset(root, target) {
  let pos = 0;
  let result = null;

  function walk(node) {
    if (result) return;

    if (node.nodeType === Node.TEXT_NODE) {
      const len = node.textContent.length;
      if (pos + len >= target) {
        result = { node, offset: target - pos };
        return;
      }
      pos += len;
      return;
    }

    if (node.nodeName === "BR") {
      if (pos + 1 > target) {
        result = { node: node.parentNode, offset: indexOfChild(node) };
        return;
      }
      pos += 1;
      return;
    }

    if (node.nodeName === "DIV" && node !== root) {
      for (const child of node.childNodes) {
        walk(child);
        if (result) return;
      }
      const lastChild = node.childNodes[node.childNodes.length - 1];
      if (!lastChild || lastChild.nodeName !== "BR") {
        if (pos + 1 > target) {
          result = { node, offset: node.childNodes.length };
          return;
        }
        pos += 1;
      }
      return;
    }

    for (const child of node.childNodes) {
      walk(child);
      if (result) return;
    }
  }

  walk(root);
  return result;
}

function indexOfChild(node) {
  let i = 0;
  let sibling = node;
  while ((sibling = sibling.previousSibling)) i++;
  return i;
}

function rangeForCharRange(root, start, end) {
  const startPos = findNodeAtCharOffset(root, start);
  const endPos = findNodeAtCharOffset(root, end);
  if (!startPos || !endPos) return null;
  const range = document.createRange();
  range.setStart(startPos.node, startPos.offset);
  range.setEnd(endPos.node, endPos.offset);
  return range;
}

function getCursorPosition(editableDiv) {
  const sel = window.getSelection();
  if (!sel.rangeCount) return 0;

  const range = sel.getRangeAt(0);
  const endContainer = range.endContainer;
  const endOffset = range.endOffset;

  let position = 0;
  let found = false;

  function walkNodes(node) {
    if (found) return;

    if (node === endContainer) {
      if (node.nodeType === Node.TEXT_NODE) {
        position += endOffset;
      } else {
        // Element node - count children up to endOffset
        for (let i = 0; i < endOffset && i < node.childNodes.length; i++) {
          countNodeChars(node.childNodes[i]);
        }
      }
      found = true;
      return;
    }

    if (node.nodeType === Node.TEXT_NODE) {
      position += node.textContent.length;
    } else if (node.nodeName === "BR") {
      position += 1;
    } else if (node.nodeName === "DIV" && node !== editableDiv) {
      for (let child of node.childNodes) {
        walkNodes(child);
        if (found) return;
      }
      // Add 1 for the newline after the DIV, but only if it didn't end with BR
      // (BR already counted as newline, so don't double-count)
      if (!found) {
        const lastChild = node.childNodes[node.childNodes.length - 1];
        if (!lastChild || lastChild.nodeName !== "BR") {
          position += 1;
        }
      }
    } else {
      for (let child of node.childNodes) {
        walkNodes(child);
        if (found) return;
      }
    }
  }

  function countNodeChars(node) {
    if (node.nodeType === Node.TEXT_NODE) {
      position += node.textContent.length;
    } else if (node.nodeName === "BR") {
      position += 1;
    } else if (node.nodeName === "DIV") {
      for (let child of node.childNodes) {
        countNodeChars(child);
      }
      // Only add newline if DIV didn't end with BR (which already counted)
      const lastChild = node.childNodes[node.childNodes.length - 1];
      if (!lastChild || lastChild.nodeName !== "BR") {
        position += 1;
      }
    } else {
      for (let child of node.childNodes) {
        countNodeChars(child);
      }
    }
  }

  walkNodes(editableDiv);
  return position;
}

function setCursorPosition(editableDiv, position) {
  const found = findNodeAtCharOffset(editableDiv, position);
  if (!found) return;
  const range = document.createRange();
  range.setStart(found.node, found.offset);
  range.collapse(true);
  const sel = window.getSelection();
  sel.removeAllRanges();
  sel.addRange(range);
  editableDiv.focus();
}

function getContentEditableText(element) {
  if (
    element.childNodes.length === 1 &&
    element.childNodes[0].nodeName === "BR"
  ) {
    return "";
  }
  function extractText(node) {
    let text = "";
    if (node.nodeType === Node.TEXT_NODE) {
      return node.textContent;
    }
    if (node.nodeName === "BR") {
      return "\n";
    }
    const blockElements = [
      "DIV",
      "P",
      "H1",
      "H2",
      "H3",
      "H4",
      "H5",
      "H6",
      "LI",
      "BLOCKQUOTE",
    ];
    const isBlock = blockElements.includes(node.nodeName);
    for (let child of node.childNodes) {
      text += extractText(child);
    }
    if (isBlock && node !== element && text && !text.endsWith("\n")) {
      text += "\n";
    }
    return text;
  }
  let result = extractText(element);
  if (result.endsWith("\n")) {
    result = result.slice(0, -1);
  }
  return result;
}

function mentionSuggestionsTemplate({
  mentionSuggestions,
  selectedSuggestionIndex,
  onSelect,
}) {
  return html`
    <div
      class="mention-typeahead"
      id="mention-typeahead"
      data-testid="mention-typeahead"
      @mousedown=${(e) => {
        // Keep focus in the input so its blur handler doesn't close the
        // typeahead before a suggestion click lands
        e.preventDefault();
      }}
    >
      ${mentionSuggestions.length === 0
        ? html`
            <div class="mention-typeahead-empty" data-testid="empty-state">
              No results
            </div>
          `
        : ""}
      ${mentionSuggestions.map(
        (actor, index) => html`
          <div
            class="mention-suggestion ${index === selectedSuggestionIndex
              ? "selected"
              : ""}"
            data-testid="mention-suggestion"
            @click=${() => onSelect(actor)}
          >
            ${avatarTemplate({
              author: actor,
              clickAction: "none",
            })}
            <div class="mention-suggestion-text">
              <div class="mention-suggestion-name">
                ${getDisplayName(actor)}
              </div>
              <div
                class="mention-suggestion-handle"
                data-testid="mention-suggestion-handle"
              >
                @${actor.handle}
              </div>
            </div>
          </div>
        `,
      )}
    </div>
  `;
}

export class RichTextInput extends Component {
  static get observedAttributes() {
    return ["disabled", "autofocus"];
  }

  connectedCallback() {
    if (this.initialized) {
      this.paintFacets();
      return;
    }
    this.placeholder = this.getAttribute("placeholder") || "";
    this.typeaheadDirection =
      this.getAttribute("typeahead-direction") || "down";
    this.disabled = this.getAttribute("disabled") !== null;
    this.facets = [];
    this.text = "";
    this.mentionSuggestions = [];
    this.selectedSuggestionIndex = null;
    this.currentMentionQuery = null;
    this.currentMentionStart = null;
    this.currentMentionEnd = null;
    this.isComposing = false;
    this.isPasting = false;
    this.render();
    // Give the empty editable its <div><br></div> line structure so it keeps
    // its one-line height before the first input
    this.paintFacets();
    this.initialized = true;
  }

  disconnectedCallback() {
    this.closeTypeahead();
  }

  attributeChangedCallback(name) {
    if (!this.initialized) return;
    if (name === "disabled") {
      this.disabled = this.getAttribute("disabled") !== null;
      this.render();
    }
    if (name === "autofocus") {
      this.render();
    }
  }

  focus() {
    const input = this.querySelector(".rich-text-input");
    if (input) {
      input.focus();
    }
  }

  blur() {
    const input = this.querySelector(".rich-text-input");
    if (input) {
      input.blur();
    }
  }

  getCursor() {
    const input = this.querySelector(".rich-text-input");
    if (!input) return null;
    return getCursorPosition(input);
  }

  setText(text) {
    this.text = text;
    this.facets = getUnresolvedFacetsFromText(this.text);
    this.render();
    this.paintFacets();
    this.dispatchEvent(
      new CustomEvent("input", {
        detail: { text: this.text, facets: this.facets, inputType: null },
      }),
    );
  }

  setCursor(cursor) {
    const input = this.querySelector(".rich-text-input");
    if (!input) return;
    const position = Math.max(0, Math.min(this.text.length, cursor));
    setCursorPosition(input, position);
  }

  insertText(str, position) {
    const at = position ?? this.getCursor() ?? this.text.length;
    this.setText(this.text.slice(0, at) + str + this.text.slice(at));
    this.setCursor(at + str.length);
  }

  render() {
    render(
      html`
        <div class="rich-text-input-container">
          <div
            class="rich-text-input"
            data-testid="rich-text-input"
            contenteditable=${this.disabled ? "false" : "true"}
            ?autofocus=${this.hasAttribute("autofocus")}
            @input=${(e) => {
              e.stopPropagation();
              this.handleInput(e);
            }}
            @keydown=${(e) => {
              this.handleKeydown(e);
            }}
            @blur=${() => {
              this.resetMentionState();
            }}
            @compositionstart=${() => {
              this.isComposing = true;
            }}
            @compositionend=${(e) => {
              this.isComposing = false;
              this.handleInput(e);
            }}
            @paste=${(e) => {
              e.preventDefault();
              // https://stackoverflow.com/a/58980415
              const clipboard = e.clipboardData || window.clipboardData;
              // iOS share-sheet links arrive as text/uri-list with no
              // text/plain representation
              const text =
                clipboard.getData("text/plain") ||
                parseUriList(clipboard.getData("text/uri-list"));
              // execCommand fires the input event synchronously, so
              // handleInput runs while this flag is set
              this.isPasting = true;
              try {
                document.execCommand("insertText", false, text);
              } finally {
                this.isPasting = false;
              }
            }}
          ></div>
          <div
            class="rich-text-input-placeholder ${this.text.length > 0
              ? "hidden"
              : ""}"
          >
            ${this.placeholder}
          </div>
        </div>
      `,
      this,
    );
  }

  openTypeahead() {
    if (this._typeaheadHost) return;
    this._typeaheadHost = document.createElement("div");
    this._typeaheadHost.className = "mention-typeahead-host";
    this._typeaheadHost.popover = "manual";
    const parent = this.closest("dialog") ?? document.body;
    parent.appendChild(this._typeaheadHost);
    this._repositionTypeahead = () => this.positionTypeahead();
    window.addEventListener("resize", this._repositionTypeahead);
    window.addEventListener("scroll", this._repositionTypeahead, true);
    this.updateTypeahead();
    if (typeof this._typeaheadHost.showPopover === "function") {
      this._typeaheadHost.showPopover();
    }
  }

  updateTypeahead() {
    if (!this._typeaheadHost) return;
    render(
      mentionSuggestionsTemplate({
        mentionSuggestions: this.mentionSuggestions,
        selectedSuggestionIndex: this.selectedSuggestionIndex,
        onSelect: (actor) => this.selectMention(actor),
      }),
      this._typeaheadHost,
    );
    requestAnimationFrame(() => this.positionTypeahead());
  }

  closeTypeahead() {
    if (!this._typeaheadHost) return;
    window.removeEventListener("resize", this._repositionTypeahead);
    window.removeEventListener("scroll", this._repositionTypeahead, true);
    this._typeaheadHost.remove();
    this._typeaheadHost = null;
    this._repositionTypeahead = null;
  }

  positionTypeahead() {
    const typeahead = this._typeaheadHost?.querySelector("#mention-typeahead");
    const input = this.querySelector(".rich-text-input");
    if (!typeahead || !input || this.currentMentionStart === null) return;

    const range = rangeForCharRange(
      input,
      this.currentMentionStart,
      this.currentMentionStart,
    );
    if (!range) return;

    const rect = range.getBoundingClientRect();
    const inputRect = input.getBoundingClientRect();
    if (this.typeaheadDirection === "up") {
      typeahead.classList.add("mention-typeahead-above");
      typeahead.style.bottom = `${window.innerHeight - rect.top}px`;
    } else {
      typeahead.style.top = `${rect.bottom}px`;
    }
    typeahead.style.left = `${inputRect.left}px`;
  }

  paintFacets() {
    const input = this.querySelector(".rich-text-input");
    if (!input) return;
    const hadFocus = document.activeElement === input;
    const cursorPos = hadFocus ? getCursorPosition(input) : null;

    const scratch = document.createElement("div");
    render(editableContentTemplate(this.text, this.facets), scratch);
    input.innerHTML = scratch.innerHTML;

    if (hadFocus && cursorPos !== null) setCursorPosition(input, cursorPos);
  }

  detectPendingMention() {
    const input = this.querySelector(".rich-text-input");
    const cursorPosition = getCursorPosition(input);

    // Look backwards from cursor to find a potential mention
    let mentionStart = -1;
    for (let i = cursorPosition - 1; i >= 0; i--) {
      const char = this.text[i];
      if (char === "@") {
        mentionStart = i;
        break;
      }
      // Stop if we hit a space or newline
      if (char === " " || char === "\n") {
        break;
      }
    }

    if (mentionStart !== -1) {
      const query = this.text.substring(mentionStart + 1, cursorPosition);
      if (query.length > 0) {
        return { query, start: mentionStart, end: cursorPosition };
      }
    }

    return null;
  }

  async fetchMentionSuggestions(query) {
    try {
      const queryParams = new URLSearchParams({
        q: query,
        limit: "8",
      });
      const response = await fetch(
        `${TYPEAHEAD_SERVICE_URL}/xrpc/app.bsky.actor.searchActorsTypeahead?${queryParams.toString()}`,
      );
      if (response.ok) {
        const data = await response.json();
        return data.actors;
      }
    } catch (error) {
      console.error("Error fetching mention suggestions:", error);
    }

    return [];
  }

  async updateMentionSuggestions() {
    const pendingMention = this.detectPendingMention();

    if (pendingMention) {
      this.currentMentionQuery = pendingMention.query;
      this.currentMentionStart = pendingMention.start;
      this.currentMentionEnd = pendingMention.end;
      const suggestions = await this.fetchMentionSuggestions(
        pendingMention.query,
      );
      // A newer input superseded this request while it was in flight
      if (this.currentMentionQuery !== pendingMention.query) return;
      this.mentionSuggestions = suggestions;
      if (this._typeaheadHost) {
        this.updateTypeahead();
      } else {
        this.openTypeahead();
      }
    } else {
      this.resetMentionState();
    }
  }

  resetMentionState() {
    this.mentionSuggestions = [];
    this.selectedSuggestionIndex = null;
    this.currentMentionQuery = null;
    this.currentMentionStart = null;
    this.currentMentionEnd = null;
    this.closeTypeahead();
  }

  selectMention(actor) {
    if (this.currentMentionStart === null) return;

    const input = this.querySelector(".rich-text-input");
    const mention = `@${actor.handle} `;

    // Select the @query span, then replace via execCommand so the browser's
    // native undo stack records this edit alongside ordinary typing.
    const range = rangeForCharRange(
      input,
      this.currentMentionStart,
      this.currentMentionEnd,
    );
    if (range) {
      const sel = window.getSelection();
      sel.removeAllRanges();
      sel.addRange(range);
      input.focus();
      document.execCommand("insertText", false, mention);
    }

    this.resetMentionState();
  }

  handleKeydown(e) {
    if (this.mentionSuggestions.length > 0) {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        if (this.selectedSuggestionIndex === null) {
          this.selectedSuggestionIndex = 0;
        } else {
          this.selectedSuggestionIndex = Math.min(
            this.selectedSuggestionIndex + 1,
            this.mentionSuggestions.length - 1,
          );
        }
        this.updateTypeahead();
      } else if (e.key === "ArrowUp") {
        e.preventDefault();
        if (this.selectedSuggestionIndex === null) {
          this.selectedSuggestionIndex = 0;
        } else {
          this.selectedSuggestionIndex = Math.max(
            this.selectedSuggestionIndex - 1,
            0,
          );
        }
        this.updateTypeahead();
      } else if (e.key === "Enter" || e.key === "Tab") {
        e.preventDefault();
        const index = this.selectedSuggestionIndex ?? 0;
        const selectedActor = this.mentionSuggestions[index];
        if (selectedActor) {
          this.selectMention(selectedActor);
        }
      } else if (e.key === "Escape") {
        e.preventDefault();
        e.stopPropagation();
        this.resetMentionState();
      }
    } else if (this._typeaheadHost && e.key === "Escape") {
      // The typeahead can be open with no suggestions (empty state)
      e.preventDefault();
      e.stopPropagation();
      this.resetMentionState();
    }
  }

  handleInput(e) {
    this.text = getContentEditableText(e.target);

    this.facets = getUnresolvedFacetsFromText(this.text);

    // Rewriting the editable's DOM mid-composition cancels the IME, so facet
    // painting waits for compositionend
    if (!this.isComposing) {
      this.paintFacets();
    }
    this.render();

    this.updateMentionSuggestions();

    this.dispatchEvent(
      new CustomEvent("input", {
        detail: {
          text: this.text,
          facets: this.facets,
          inputType: this.isPasting ? "insertFromPaste" : e.inputType || null,
        },
      }),
    );
  }
}

RichTextInput.register();
