import { AstroError } from "astro/errors";
import type { Element, ElementContent, Text } from "hast";
import { fromHtml } from "hast-util-from-html";
import { select } from "hast-util-select";
import { toString } from "hast-util-to-string";
import { type Child, h, s } from "hastscript";
import { rehype } from "rehype";
import { CONTINUE, SKIP, visit } from "unist-util-visit";

import { Icons } from "./Icons";
import { definitions } from "./file-tree-icons";

declare module "vfile" {
  interface DataMap {
    directoryLabel: string;
  }
}

const folderIcon = makeSVGIcon(Icons["seti:folder"]);
const defaultFileIcon = makeSVGIcon(Icons["seti:default"]);

/**
 * Process the HTML for a file tree to create the necessary markup for each file and directory
 * including icons.
 * @param html Inner HTML passed to the `<FileTree>` component.
 * @param directoryLabel The localized label for a directory.
 * @returns The processed HTML for the file tree.
 */
export function processFileTree(html: string, directoryLabel: string) {
  const file = fileTreeProcessor.processSync({
    data: { directoryLabel },
    value: html,
  });

  return file.toString();
}

/** Rehype processor to extract file tree data and turn each entry into its associated markup. */
const fileTreeProcessor = rehype()
  .data("settings", { fragment: true })
  .use(function fileTree() {
    return (tree: Element, file) => {
      const { directoryLabel } = file.data;

      validateFileTree(tree);

      visit(tree, "element", (node) => {
        // Strip nodes that only contain newlines.
        node.children = node.children.filter(
          (child) =>
            child.type === "comment" ||
            child.type !== "text" ||
            !/^\n+$/.test(child.value)
        );

        // Skip over non-list items.
        if (node.tagName !== "li") return CONTINUE;

        const [firstChild, ...otherChildren] = node.children;

        // Keep track of comments associated with the current file or directory.
        const comment: Child[] = [];

        // Extract text comment that follows the file name, e.g. `README.md This is a comment`
        if (firstChild?.type === "text") {
          const [filename, ...fragments] = firstChild.value.split(" ");
          firstChild.value = filename || "";
          const textComment = fragments.join(" ").trim();
          if (textComment.length > 0) {
            comment.push(fragments.join(" "));
          }
        }

        // Comments may not always be entirely part of the first child text node,
        // e.g. `README.md This is an __important__ comment` where the `__important__` and `comment`
        // nodes would also be children of the list item node.
        const subTreeIndex = otherChildren.findIndex(
          (child) => child.type === "element" && child.tagName === "ul"
        );
        const commentNodes =
          subTreeIndex > -1
            ? otherChildren.slice(0, subTreeIndex)
            : [...otherChildren];
        otherChildren.splice(
          0,
          subTreeIndex > -1 ? subTreeIndex : otherChildren.length
        );
        comment.push(...commentNodes);

        const firstChildTextContent = firstChild ? toString(firstChild) : "";

        // Decide a node is a directory if it ends in a `/` or contains another list.
        const isDirectory =
          /\/\s*$/.test(firstChildTextContent) ||
          otherChildren.some(
            (child) => child.type === "element" && child.tagName === "ul"
          );
        // A placeholder is a node that only contains 3 dots or an ellipsis.
        const isPlaceholder = /^\s*(\.{3}|…)\s*$/.test(firstChildTextContent);
        // A node is highlighted if its first child is bold text, e.g. `**README.md**`.
        const isHighlighted =
          firstChild?.type === "element" && firstChild.tagName === "strong";

        // Create an icon for the file or directory (placeholder do not have icons).
        const icon = h(
          "span",
          isDirectory ? folderIcon : getFileIcon(firstChildTextContent)
        );
        if (isDirectory) {
          // Add a screen reader only label for directories before the icon so that it is announced
          // as such before reading the directory name.
          icon.children.unshift(
            h("span", { class: "sr-only" }, directoryLabel as any)
          );
        }

        // Add classes and data attributes to the list item node.
        node.properties.class = isDirectory ? "directory" : "file";
        if (isPlaceholder) node.properties.class += " empty";

        // Create the tree entry node that contains the icon, file name and comment which will end up
        // as the list item’s children.
        const treeEntryChildren: Child[] = [
          h("span", { class: isHighlighted ? "highlight" : "" }, [
            isPlaceholder ? null : icon,
            firstChild,
          ]),
        ];

        if (comment.length > 0) {
          treeEntryChildren.push(
            makeText(" "),
            h("span", { class: "comment" }, ...comment)
          );
        }

        const treeEntry = h(
          "span",
          { class: "tree-entry" },
          ...treeEntryChildren
        );

        if (isDirectory) {
          const hasContents = otherChildren.length > 0;

          node.children = [
            h("details", { open: hasContents }, [
              h("summary", treeEntry),
              ...(hasContents ? otherChildren : [h("ul", h("li", "…"))]),
            ]),
          ];

          // Continue down the tree.
          return CONTINUE;
        }

        node.children = [treeEntry, ...otherChildren];

        // Files can’t contain further files or directories, so skip iterating children.
        return SKIP;
      });
    };
  });

/** Make a text node with the pass string as its contents. */
function makeText(value = ""): Text {
  return { type: "text", value };
}

/** Make a node containing an SVG icon from the passed HTML string. */
function makeSVGIcon(svgString: string) {
  return s(
    "svg",
    {
      width: 16,
      height: 16,
      class: "tree-icon",
      "aria-hidden": "true",
      viewBox: "0 0 24 24",
    },
    fromHtml(svgString, { fragment: true })
  );
}

/** Return the icon for a file based on its file name. */
function getFileIcon(fileName: string) {
  const name = getFileIconName(fileName);
  if (!name) return defaultFileIcon;
  if (name in Icons) {
    const path = Icons[name as keyof typeof Icons];
    return makeSVGIcon(path);
  }
  return defaultFileIcon;
}

/** Return the icon name for a file based on its file name. */
function getFileIconName(fileName: string) {
  let icon: string | undefined = definitions.files[fileName];
  if (icon) return icon;
  icon = getFileIconTypeFromExtension(fileName);
  if (icon) return icon;
  for (const [partial, partialIcon] of Object.entries(definitions.partials)) {
    if (fileName.includes(partial)) return partialIcon;
  }
  return icon;
}

/**
 * Get an icon from a file name based on its extension.
 * Note that an extension in Seti is everything after a dot, so `README.md` would be `.md` and
 * `name.with.dots` will try to look for an icon for `.with.dots` and then `.dots` if the first one
 * is not found.
 */
function getFileIconTypeFromExtension(fileName: string) {
  const firstDotIndex = fileName.indexOf(".");
  if (firstDotIndex === -1) return;
  let extension = fileName.slice(firstDotIndex);
  while (extension !== "") {
    const icon = definitions.extensions[extension];
    if (icon) return icon;
    const nextDotIndex = extension.indexOf(".", 1);
    if (nextDotIndex === -1) return;
    extension = extension.slice(nextDotIndex);
  }
  return;
}

/** Validate that the user provided HTML for a file tree is valid. */
function validateFileTree(tree: Element) {
  const rootElements = tree.children.filter(isElementNode);
  const [rootElement] = rootElements;

  if (rootElements.length === 0) {
    throwFileTreeValidationError(
      "The `<FileTree>` component expects its content to be a single unordered list but found no child elements."
    );
  }

  if (rootElements.length !== 1) {
    throwFileTreeValidationError(
      `The \`<FileTree>\` component expects its content to be a single unordered list but found multiple child elements: ${rootElements
        .map((element) => `\`<${element.tagName}>\``)
        .join(" - ")}.`
    );
  }

  if (!rootElement || rootElement.tagName !== "ul") {
    throwFileTreeValidationError(
      `The \`<FileTree>\` component expects its content to be an unordered list but found the following element: \`<${rootElement?.tagName}>\`.`
    );
  }

  const listItemElement = select("li", rootElement);

  if (!listItemElement) {
    throwFileTreeValidationError(
      "The `<FileTree>` component expects its content to be an unordered list with at least one list item."
    );
  }
}

function isElementNode(node: ElementContent): node is Element {
  return node.type === "element";
}

/** Throw a validation error for a file tree linking to the documentation. */
function throwFileTreeValidationError(message: string): never {
  throw new AstroError(
    message,
    "To learn more about the `<FileTree>` component, see https://starlight.astro.build/guides/components/#file-tree"
  );
}

export interface Definitions {
  files: Record<string, string>;
  extensions: Record<string, string>;
  partials: Record<string, string>;
}
