import {
  collection,
  Decoration,
  DecorationSet,
  Plugin,
  PluginKey,
  type PMNode,
} from '@learnitive/prosemirror-plugins';
import { createHighlightPlugin, type Parser } from 'prosemirror-highlight';
import type { EditorView } from 'prosemirror-view';
import {
  createBlockActionButton,
  deleteBlockAt,
  isBlockActionEvent,
  markEditorChrome,
} from './block-action-button';
import {
  CODE_BLOCK_LANGUAGE_OPTIONS,
  getRawCodeBlockLanguage,
  normalizeCodeBlockLanguage,
  runnableCodeBlockLanguage,
} from './code-highlight-languages';

const CODE_ACTIONS_PLUGIN_KEY = new PluginKey('code-actions');
export const MARKDOWN_CODE_RUN_EVENT = 'learnitive:markdown-code-run';
const COPY_FEEDBACK_TIMEOUT_MS = 1200;
const copiedFeedbackUntilByPos = new Map<number, number>();

type ClipboardApi = {
  writeText: (text: string) => Promise<void>;
};
type CopyTextarea = {
  value: string;
  setAttribute: (name: string, value: string) => void;
  style: {
    position: string;
    left: string;
    top: string;
    opacity: string;
    pointerEvents: string;
  };
  focus: () => void;
  select: () => void;
  remove: () => void;
};
type CopyDocument = {
  createElement: (tagName: 'textarea') => CopyTextarea;
  body: {
    appendChild: (node: CopyTextarea) => void;
  } | null;
  execCommand?: (command: string) => boolean;
};
type MinimalEditorView = Pick<EditorView, 'state' | 'dispatch' | 'focus'>;
type CodeBlockPosResolver = () => number | undefined;
type CodeActionsState = {
  decorations: DecorationSet;
  signature: string;
};

let loadedHighlightParser: Parser | undefined;
let highlightParserPromise: Promise<void> | undefined;

export function setupCodeHighlight() {
  return collection({
    id: 'code-highlight',
    plugin: {
      codeHighlight: createHighlightPlugin({
        parser: lazyCodeHighlightParser,
        nodeTypes: ['code_block'],
        languageExtractor: (node) =>
          normalizeCodeBlockLanguage(node.attrs.language),
      }),
      codeBlockActions: new Plugin({
        key: CODE_ACTIONS_PLUGIN_KEY,
        state: {
          init: (_, state) => createActionState(state.doc),
          apply(tr, old: CodeActionsState) {
            const mappedDecorations = old.decorations.map(tr.mapping, tr.doc);
            if (!tr.docChanged && !tr.getMeta(CODE_ACTIONS_PLUGIN_KEY)) {
              return {
                ...old,
                decorations: mappedDecorations,
              };
            }

            const signature = createActionSignature(tr.doc);
            const expectedDecorationCount = countCodeActionDecorations(tr.doc);
            if (
              !tr.getMeta(CODE_ACTIONS_PLUGIN_KEY) &&
              signature === old.signature &&
              mappedDecorations.find().length === expectedDecorationCount
            ) {
              return {
                decorations: mappedDecorations,
                signature,
              };
            }
            return createActionState(tr.doc, signature);
          },
        },
        props: {
          decorations(state) {
            return CODE_ACTIONS_PLUGIN_KEY.getState(state)?.decorations;
          },
        },
      }),
    },
  });
}

const lazyCodeHighlightParser: Parser = (options) => {
  const language = normalizeCodeBlockLanguage(options.language);
  if (!options.content || language === 'text' || language === 'plaintext') {
    return [];
  }

  if (!loadedHighlightParser) {
    highlightParserPromise ??= import('./code-highlight-shiki')
      .then(({ createCodeHighlightParser }) => {
        const parser = createCodeHighlightParser();
        loadedHighlightParser = parser;
        const result = parser({
          ...options,
          language,
        });
        return Array.isArray(result) ? undefined : result;
      })
      .catch((error: unknown) => {
        highlightParserPromise = undefined;
        throw error;
      });
    return highlightParserPromise;
  }

  return loadedHighlightParser({
    ...options,
    language,
  });
};

function createActionState(
  doc: PMNode,
  signature = createActionSignature(doc),
): CodeActionsState {
  return {
    decorations: createActionDecorations(doc),
    signature,
  };
}

function createActionSignature(doc: PMNode): string {
  const languages: string[] = [];
  doc.descendants((node) => {
    if (node.type.name === 'code_block') {
      languages.push(getRawCodeBlockLanguage(node.attrs.language));
      return false;
    }
    return true;
  });
  return languages.join('\0');
}

function countCodeActionDecorations(doc: PMNode): number {
  let codeBlockCount = 0;
  doc.descendants((node) => {
    if (node.type.name === 'code_block') {
      codeBlockCount += 1;
      return false;
    }
    return true;
  });
  return codeBlockCount * 2;
}

function createActionDecorations(doc: PMNode): DecorationSet {
  const decorations: Decoration[] = [];

  doc.descendants((node, pos) => {
    if (node.type.name !== 'code_block') {
      return true;
    }

    const rawLanguage = getRawCodeBlockLanguage(node.attrs.language);
    decorations.push(
      Decoration.widget(
        pos + 1,
        (view, getPos) =>
          createLanguageBadgeWidget(
            rawLanguage,
            codeBlockPosFromWidget(getPos),
            view,
          ),
        {
          key: `code-language:${pos}:${rawLanguage}`,
          side: -1,
          ignoreSelection: true,
          stopEvent: isBlockActionEvent,
        },
      ),
    );
    decorations.push(
      Decoration.widget(
        pos + 1,
        (view, getPos) =>
          createCopyButtonWidget(rawLanguage, codeBlockPosFromWidget(getPos), view),
        {
          key: `code-copy:${pos}`,
          side: -1,
          ignoreSelection: true,
          stopEvent: isBlockActionEvent,
        },
      ),
    );

    return true;
  });

  return DecorationSet.create(doc, decorations);
}

function codeBlockPosFromWidget(getWidgetPos: CodeBlockPosResolver) {
  return () => {
    const widgetPos = getWidgetPos();
    return widgetPos === undefined ? undefined : widgetPos - 1;
  };
}

function createCopyButtonWidget(
  language: string,
  getCodeBlockPos: CodeBlockPosResolver,
  editorView: EditorView,
): HTMLElement {
  const copyLabel = t.app.editor.codeBlock.copy;
  const wrapper = document.createElement('span');
  wrapper.className = 'prosemirror-code-copy-widget';
  markEditorChrome(wrapper);

  const button = createBlockActionButton({
    className: 'prosemirror-code-copy-button',
    text: copyLabel,
    label: copyLabel,
    onClick: async () => {
      const codeBlockPos = getCodeBlockPos();
      if (codeBlockPos === undefined) {
        return;
      }

      const copied = await copyTextToClipboard(
        getCodeBlockText(editorView, codeBlockPos),
      );
      if (!copied) {
        return;
      }
      showCopyFeedback(button, codeBlockPos);
      editorView.focus();
    },
  });
  updateCopyButtonText(button, getCodeBlockPos());

  const runnableLanguage = runnableCodeBlockLanguage(language);
  if (runnableLanguage) {
    const runButton = createBlockActionButton({
      className: 'prosemirror-code-run-button',
      text: t.app.editor.codeBlock.run,
      label: t.app.editor.codeBlock.runLabel,
      onClick: () => {
        const codeBlockPos = getCodeBlockPos();
        const code = getCodeBlockText(editorView, codeBlockPos);
        if (codeBlockPos === undefined) return;
        editorView.dom.dispatchEvent(new CustomEvent(MARKDOWN_CODE_RUN_EVENT, {
          bubbles: true,
          detail: { language: runnableLanguage, sourceLanguage: language, code },
        }));
      },
    });
    wrapper.append(runButton);
  }

  const deleteButton = createBlockActionButton({
    className: 'prosemirror-block-delete-button',
    text: t.app.editor.codeBlock.delete,
    label: t.app.editor.codeBlock.deleteLabel,
    onClick: () => deleteBlockAt(editorView, getCodeBlockPos(), 'code_block'),
  });

  wrapper.append(button, deleteButton);
  return wrapper;
}

function showCopyFeedback(button: HTMLButtonElement, codeBlockPos: number) {
  const until = Date.now() + COPY_FEEDBACK_TIMEOUT_MS;
  copiedFeedbackUntilByPos.set(codeBlockPos, until);
  button.textContent = t.app.editor.codeBlock.copied;
  scheduleCopyFeedbackReset(button, codeBlockPos, until);
}

function updateCopyButtonText(
  button: HTMLButtonElement,
  codeBlockPos: number | undefined,
) {
  if (codeBlockPos === undefined) {
    button.textContent = t.app.editor.codeBlock.copy;
    return;
  }

  const until = copiedFeedbackUntilByPos.get(codeBlockPos);
  if (!until || until <= Date.now()) {
    copiedFeedbackUntilByPos.delete(codeBlockPos);
    button.textContent = t.app.editor.codeBlock.copy;
    return;
  }

  button.textContent = t.app.editor.codeBlock.copied;
  scheduleCopyFeedbackReset(button, codeBlockPos, until);
}

function scheduleCopyFeedbackReset(
  button: HTMLButtonElement,
  codeBlockPos: number,
  until: number,
) {
  globalThis.setTimeout(
    () => {
      const activeUntil = copiedFeedbackUntilByPos.get(codeBlockPos);
      if (activeUntil !== undefined && activeUntil > until) {
        return;
      }
      copiedFeedbackUntilByPos.delete(codeBlockPos);
      button.textContent = t.app.editor.codeBlock.copy;
    },
    Math.max(0, until - Date.now()),
  );
}

function getCodeBlockText(
  editorView: EditorView,
  codeBlockPos: number | undefined,
): string {
  if (editorView.isDestroyed || codeBlockPos === undefined) {
    return '';
  }
  const node = editorView.state.doc.nodeAt(codeBlockPos);
  if (node?.type.name !== 'code_block') {
    return '';
  }
  return node.textContent || '';
}

function createLanguageBadgeWidget(
  language: string,
  getCodeBlockPos: CodeBlockPosResolver,
  editorView: EditorView,
): HTMLElement {
  const wrapper = document.createElement('span');
  wrapper.className = 'prosemirror-code-language-widget';
  markEditorChrome(wrapper);

  const select = document.createElement('select');
  select.className = 'prosemirror-code-language-select';
  select.setAttribute('aria-label', t.app.editor.codeBlock.editLanguage);
  const rawLanguage = getRawCodeBlockLanguage(language);
  const knownValues = new Set<string>(CODE_BLOCK_LANGUAGE_OPTIONS.map((item) => item.value));
  if (rawLanguage && !knownValues.has(rawLanguage)) {
    const custom = document.createElement('option');
    custom.value = rawLanguage;
    custom.textContent = `${rawLanguage} · custom`;
    select.append(custom);
  }
  for (const item of CODE_BLOCK_LANGUAGE_OPTIONS) {
    const option = document.createElement('option');
    option.value = item.value;
    option.textContent = item.label;
    select.append(option);
  }
  select.value = rawLanguage;
  select.addEventListener('change', () => {
    applyLanguageChange(editorView, getCodeBlockPos(), select.value, rawLanguage);
  });
  select.addEventListener('pointerdown', (event) => event.stopPropagation());
  wrapper.append(select);
  return wrapper;
}

function applyLanguageChange(
  editorView: EditorView,
  codeBlockPos: number | undefined,
  value: string,
  previousValue: string,
): boolean {
  if (!editorView || editorView.isDestroyed || codeBlockPos === undefined) {
    return false;
  }

  const nextLanguage = value.trim().toLowerCase();
  if (nextLanguage === previousValue) {
    editorView.focus();
    return false;
  }

  const changed = setCodeBlockLanguage(editorView, codeBlockPos, nextLanguage);
  editorView.focus();
  return changed;
}

function setCodeBlockLanguage(
  editorView: MinimalEditorView,
  codeBlockPos: number,
  language: string,
): boolean {
  const node = editorView.state.doc.nodeAt(codeBlockPos);
  if (node?.type.name !== 'code_block') {
    return false;
  }
  if (node.attrs.language === language) {
    return false;
  }

  const nextAttrs = {
    ...node.attrs,
    language,
  };
  editorView.dispatch(
    editorView.state.tr
      .setNodeMarkup(codeBlockPos, undefined, nextAttrs)
      .setMeta(CODE_ACTIONS_PLUGIN_KEY, true),
  );
  return true;
}

export async function copyTextToClipboard(
  text: string,
  options?: {
    clipboard?: ClipboardApi;
    document?: CopyDocument;
  },
): Promise<boolean> {
  const clipboard =
    options?.clipboard ?? globalThis.navigator?.clipboard ?? undefined;

  if (clipboard?.writeText) {
    try {
      await clipboard.writeText(text);
      return true;
    } catch {
      // Fall through to the document fallback.
    }
  }

  const doc = options?.document ?? getCopyDocument(globalThis.document);
  if (!doc?.body) {
    return false;
  }

  const textarea = doc.createElement('textarea');
  textarea.value = text;
  textarea.setAttribute('readonly', 'readonly');
  textarea.style.position = 'fixed';
  textarea.style.left = '-9999px';
  textarea.style.top = '0';
  textarea.style.opacity = '0';
  textarea.style.pointerEvents = 'none';
  doc.body.appendChild(textarea);
  textarea.focus();
  textarea.select();

  try {
    if (typeof doc.execCommand !== 'function') {
      return false;
    }
    return doc.execCommand('copy');
  } catch {
    return false;
  } finally {
    textarea.remove();
  }
}

function getCopyDocument(value: unknown): CopyDocument | undefined {
  if (!isCopyDocument(value)) {
    return undefined;
  }

  return value;
}

function isCopyDocument(value: unknown): value is CopyDocument {
  return (
    typeof value === 'object' &&
    value !== null &&
    'createElement' in value &&
    'body' in value
  );
}
