import type { Root, Parent } from 'myst-spec';
import type { Plugin } from 'unified';
import type { VFile } from 'vfile';
import type { GenericNode } from 'myst-common';
import { fileError, fileWarn, toText, getMetadataTags } from 'myst-common';
import { captionHandler, containerHandler, getDefaultCaptionSupplement } from './container.js';
import type {
  Handler,
  ITypstSerializer,
  TypstResult,
  Options,
  StateData,
  RenderChildrenOptions,
} from './types.js';
import {
  getLatexImageWidth,
  hrefToLatexText,
  nodeOnlyHasTextChildren,
  stringToTypstMath,
  stringToTypstText,
} from './utils.js';
import MATH_HANDLERS, { resolveRecursiveCommands } from './math.js';
import { select, selectAll } from 'unist-util-select';
import type { Admonition, Code, CrossReference, FootnoteDefinition, TabItem } from 'myst-spec-ext';
import { tableCellHandler, tableHandler, tableRowHandler } from './table.js';
import { proofHandlers } from './proofs.js';

export type { TypstResult } from './types.js';

const admonition = `#let admonition(body, heading: none, color: blue) = {
  let stroke = (left: 2pt + color.darken(20%))
  let fill = color.lighten(80%)
  let title
  if heading != none {
    title = block(width: 100%, inset: (x: 8pt, y: 4pt), fill: fill, below: 0pt, radius: (top-right: 2pt))[#text(11pt, weight: "bold")[#heading]]
  }
  block(width: 100%, stroke: stroke, [
    #title
  #block(fill: luma(240), width: 100%, inset: 8pt, radius: (bottom-right: 2pt))[#body]
])
}`;
const admonitionMacros = {
  attention:
    '#let attentionBlock(body, heading: [Attention]) = admonition(body, heading: heading, color: yellow)',
  caution:
    '#let cautionBlock(body, heading: [Caution]) = admonition(body, heading: heading, color: yellow)',
  danger:
    '#let dangerBlock(body, heading: [Danger]) = admonition(body, heading: heading, color: red)',
  error: '#let errorBlock(body, heading: [Error]) = admonition(body, heading: heading, color: red)',
  hint: '#let hintBlock(body, heading: [Hint]) = admonition(body, heading: heading, color: green)',
  important:
    '#let importantBlock(body, heading: [Important]) = admonition(body, heading: heading, color: blue)',
  note: '#let noteBlock(body, heading: [Note]) = admonition(body, heading: heading, color: blue)',
  seealso:
    '#let seealsoBlock(body, heading: [See Also]) = admonition(body, heading: heading, color: green)',
  tip: '#let tipBlock(body, heading: [Tip]) = admonition(body, heading: heading, color: green)',
  warning:
    '#let warningBlock(body, heading: [Warning]) = admonition(body, heading: heading, color: yellow)',
};

const tabSet = `
#let tabSet(body) = {
  block(width: 100%, stroke: luma(240), [#body])
}`;
const tabItem = `
#let tabItem(body, heading: none) = {
  let title
  if heading != none {
    title = block(width: 100%, inset: (x: 8pt, y: 4pt), fill: luma(250))[#text(9pt, weight: "bold")[#heading]]
  }
  block(width: 100%, [
    #title
    #block(width: 100%, inset: (x: 8pt, bottom: 8pt))[#body]
  ])
}`;

const INDENT = '  ';

const linkHandler = (node: any, state: ITypstSerializer) => {
  const href = node.url;
  state.write('#link("');
  state.write(hrefToLatexText(href));
  state.write('")');
  if (node.children.length && node.children[0].value !== href) {
    state.write('[');
    state.renderChildren(node);
    state.write(']');
  }
};

function prevCharacterIsText(parent: GenericNode, node: GenericNode): boolean {
  const ind = parent?.children?.findIndex((n: GenericNode) => n === node);
  if (!ind) return false;
  const prev = parent?.children?.[ind - 1];
  if (!prev?.value) return false;
  return (prev?.type === 'text' && !!prev.value.match(/[a-zA-Z0-9\-_]$/)) || false;
}

function nextCharacterIsText(parent: GenericNode, node: GenericNode): boolean {
  const ind = parent?.children?.findIndex((n: GenericNode) => n === node);
  if (!ind) return false;
  const next = parent?.children?.[ind + 1];
  if (!next?.value) return false;
  return (next?.type === 'text' && !!next.value.match(/^[a-zA-Z0-9\-_]/)) || false;
}

const handlers: Record<string, Handler> = {
  text(node, state) {
    // We do not want markdown formatting to be carried over to typst
    // As the meaning in lists, etc. is different
    state.text(node.value.replaceAll('\n', ' '));
  },
  paragraph(node, state) {
    const { identifier } = node;
    const after = identifier ? ` <${identifier}>` : undefined;
    state.renderChildren(node, 2, { after });
  },
  heading(node, state) {
    const { depth, identifier, enumerated, implicit } = node;
    state.write(`${Array(depth).fill('=').join('')} `);
    state.renderChildren(node);
    if (enumerated !== false && identifier && !implicit) {
      // Implicit labels can have duplicates and stop typst from compiling
      state.write(` <${identifier}>`);
    }
    state.write('\n\n');
  },
  block(node, state) {
    const metadataTags = getMetadataTags(node);
    if (metadataTags.includes('no-typst')) return;
    if (metadataTags.includes('no-pdf')) return;
    if (node.visibility === 'remove' || node.visibility === 'hide') return;
    if (metadataTags.includes('page-break') || metadataTags.includes('new-page')) {
      state.write('#pagebreak(weak: true)\n');
    }
    state.renderChildren(node, 2);
  },
  blockquote(node, state) {
    if (state.data.isInBlockquote) {
      state.renderChildren(node);
      return;
    }
    state.write('#quote(block: true)[');
    state.renderChildren(node);
    state.write(']');
  },
  definitionList(node, state) {
    let dedent = false;
    if (!state.data.definitionIndent) {
      state.data.definitionIndent = 2;
    } else {
      state.write(`#set terms(indent: ${state.data.definitionIndent}em)`);
      state.data.definitionIndent += 2;
      dedent = true;
    }
    state.renderChildren(node, 1);
    state.data.definitionIndent -= 2;
    if (dedent) state.write(`#set terms(indent: ${state.data.definitionIndent - 2}em)\n`);
  },
  definitionTerm(node, state) {
    state.ensureNewLine();
    state.write('/ ');
    state.renderChildren(node);
    state.write(': ');
  },
  definitionDescription(node, state) {
    state.renderChildren(node);
  },
  code(node: Code, state) {
    if (node.visibility === 'remove' || node.visibility === 'hide') return;
    let ticks = '```';
    while (node.value.includes(ticks)) {
      ticks += '`';
    }
    const start = `${ticks}${node.lang ?? ''}\n`;
    const end = `\n${ticks}`;
    state.write(start);
    state.write(node.value);
    state.write(end);
    state.ensureNewLine(true);
    state.addNewLine();
  },
  list(node, state) {
    const setStart = node.ordered && node.start && node.start !== 1;
    if (setStart) {
      state.write(`#set enum(start: ${node.start})`);
    }
    state.data.list ??= { env: [] };
    state.data.list.env.push(node.ordered ? '+' : '-');
    state.renderChildren(node, setStart ? 1 : 2);
    state.data.list.env.pop();
    if (setStart) {
      state.write('#set enum(start: 1)\n\n');
    }
  },
  listItem(node, state) {
    const listEnv = state.data.list?.env ?? [];
    const tabs = Array(Math.max(listEnv.length - 1, 0))
      .fill(INDENT)
      .join('');
    const env = listEnv.slice(-1)[0] ?? '-';
    state.ensureNewLine();
    state.write(`${tabs}${env} `);
    state.renderChildren(node, 1);
  },
  thematicBreak(node, state) {
    state.write('#line(length: 100%, stroke: gray)\n\n');
  },
  ...MATH_HANDLERS,
  mystRole(node, state) {
    state.renderChildren(node);
  },
  mystDirective(node, state) {
    state.renderChildren(node, 2);
  },
  comment(node, state) {
    state.ensureNewLine();
    if (node.value?.includes('\n')) {
      state.write(`/*\n${node.value}\n*/\n\n`);
    } else {
      state.write(`// ${node.value ?? ''}\n\n`);
    }
  },
  strong(node, state, parent) {
    const prev = prevCharacterIsText(parent, node);
    const next = nextCharacterIsText(parent, node);
    if (nodeOnlyHasTextChildren(node) && !(prev || next)) {
      state.write('*');
      state.renderChildren(node);
      state.write('*');
    } else {
      state.renderInlineEnvironment(node, 'strong');
    }
  },
  emphasis(node, state, parent) {
    const prev = prevCharacterIsText(parent, node);
    const next = nextCharacterIsText(parent, node);
    if (nodeOnlyHasTextChildren(node) && !prev && !next) {
      state.write('_');
      state.renderChildren(node);
      state.write('_');
    } else {
      state.renderInlineEnvironment(node, 'emph');
    }
  },
  underline(node, state) {
    state.renderInlineEnvironment(node, 'underline');
  },
  smallcaps(node, state) {
    state.renderInlineEnvironment(node, 'smallcaps');
  },
  inlineCode(node, state) {
    let ticks = '`';
    // inlineCode can sometimes have children (e.g. from latex)
    const value = toText(node);
    // Double ticks create empty inline code; we never want that for start/end
    while (ticks === '``' || value.includes(ticks)) {
      ticks += '`';
    }
    state.write(ticks);
    if (value.startsWith('`')) state.write(' ');
    state.write(value);
    if (value.endsWith('`')) state.write(' ');
    state.write(ticks);
  },
  subscript(node, state) {
    state.renderInlineEnvironment(node, 'sub');
  },
  superscript(node, state) {
    state.renderInlineEnvironment(node, 'super');
  },
  delete(node, state) {
    state.renderInlineEnvironment(node, 'strike');
  },
  break(node, state) {
    state.write(' \\');
    state.ensureNewLine();
  },
  abbreviation(node, state) {
    state.renderChildren(node);
  },
  inlineExpression(node, state) {
    // TODO: This is **very** simple at the moment
    // It will work for inline nodes likely only, we can make it better soon
    fileWarn(state.file, 'inlineExpression rendering in typst is in beta', {
      node,
      note: 'Rendering will work only for text nodes',
    });
    state.renderChildren(node);
  },
  link: linkHandler,
  admonition(node: Admonition, state) {
    state.useMacro(admonition);
    state.ensureNewLine();
    const title = select('admonitionTitle', node);
    if (!node.kind) {
      fileError(state.file, `Unknown admonition kind`, {
        node,
        source: 'myst-to-typst',
      });
      return;
    }
    state.useMacro(admonitionMacros[node.kind]);
    state.write(`#${node.kind}Block`);
    if (title && toText(title).toLowerCase().replaceAll(' ', '') !== node.kind) {
      state.write('(heading: [');
      state.renderChildren(title);
      state.write('])');
    }
    state.write('[\n');
    state.renderChildren(node);
    state.write('\n]\n\n');
  },
  admonitionTitle() {
    return;
  },
  table: tableHandler,
  tableRow: tableRowHandler,
  tableCell: tableCellHandler,
  image(node, state) {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { width: nodeWidth, url: nodeSrc, align } = node;
    const src = nodeSrc;
    const width = getLatexImageWidth(nodeWidth);
    const command = state.data.isInTable || !state.data.isInFigure ? '#image' : 'image';
    state.write(`${command}("${src}"`);
    if (!state.data.isInTable) {
      state.write(`, width: ${width}`);
    }
    state.write(')\n\n');
  },
  iframe(node, state) {
    const image = node.children?.[0];
    if (!image || image.placeholder !== true) return;
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { width: nodeWidth, url: nodeSrc, align } = image;
    const src = nodeSrc;
    const width = getLatexImageWidth(nodeWidth);
    state.write(`#image("${src}"`);
    if (!state.data.isInTable) {
      state.write(`, width: ${width}`);
    }
    state.write(')\n\n');
  },
  container: containerHandler,
  caption: captionHandler,
  legend: captionHandler,
  captionNumber: () => undefined,
  crossReference(node: CrossReference, state, parent) {
    if (node.remoteBaseUrl) {
      // We don't want to handle remote references, treat them as links
      const url =
        node.remoteBaseUrl +
        (node.url === '/' ? '' : node.url ?? '') +
        (node.html_id ? `#${node.html_id}` : '');
      linkHandler({ ...node, url: url }, state);
      return;
    }
    const id = node.identifier;
    if (node.children && node.children.length > 0) {
      state.write(`#link(<${id}>)[`);
      state.renderChildren(node);
      state.write(']');
    } else {
      // Note that we don't need to protect against the previous character as text
      const next = nextCharacterIsText(parent, node);
      state.write(next ? `#[@${id}]` : `@${id}`);
    }
  },
  citeGroup(node, state) {
    state.renderChildren(node, 0, { delim: ' ' });
  },
  cite(node, state) {
    const needsLabel = !/^[a-zA-Z0-9_\-:.]+$/.test(node.label);
    const label = needsLabel ? `label("${node.label}")` : `<${node.label}>`;
    state.write(`#cite(${label}`);
    if (node.kind === 'narrative') state.write(`, form: "prose"`);
    // node.prefix not supported by typst: see https://github.com/typst/typst/issues/1139
    if (node.suffix) state.write(`, supplement: [${node.suffix}]`);
    state.write(`)`);
  },
  embed(node, state) {
    state.renderChildren(node, 2);
  },
  include(node, state) {
    state.renderChildren(node, 2);
  },
  footnoteReference(node, state) {
    if (!node.identifier) return;
    const footnote = state.footnotes[node.identifier];
    if (!footnote) {
      fileError(state.file, `Unknown footnote identifier "${node.identifier}"`, {
        node,
        source: 'myst-to-typst',
      });
      return;
    }
    state.write('#footnote[');
    state.renderChildren(footnote);
    state.write(']');
  },
  footnoteDefinition() {
    // Nothing!
  },
  // si(node, state) {
  //   // state.useMacro('siunitx');
  //   if (node.number == null) {
  //     state.write(`\\unit{${node.units?.map((u: string) => `\\${u}`).join('') ?? ''}}`);
  //   } else {
  //     state.write(
  //       `\\qty{${node.number}}{${node.units?.map((u: string) => `\\${u}`).join('') ?? ''}}`,
  //     );
  //   }
  // },
  div(node, state) {
    state.renderChildren(node, 1);
  },
  span(node, state) {
    state.renderChildren(node, 0, { trimEnd: false });
  },
  raw(node, state) {
    if (node.typst) {
      state.write(node.typst);
    } else if (node.children?.length) {
      state.renderChildren(node, undefined, { trimEnd: false });
    }
  },
  tabSet(node, state) {
    state.useMacro(tabSet);
    state.write('#tabSet[\n');
    state.renderChildren(node);
    state.write('\n]\n\n');
  },
  tabItem(node: TabItem, state) {
    state.useMacro(tabItem);
    state.ensureNewLine();
    const title = node.title;
    state.write(`#tabItem(heading: [${title}])[\n`);
    state.renderChildren(node);
    state.write('\n]\n\n');
  },
  card(node, state) {
    if (node.url) {
      node.children?.push({ type: 'paragraph', children: [{ type: 'text', value: node.url }] });
    }
    state.renderChildren(node);
    state.ensureNewLine();
    state.write('\n');
  },
  cardTitle(node, state) {
    state.write('*');
    state.renderChildren(node);
    state.write('*');
    state.ensureNewLine();
    state.write('\n');
  },
  root(node, state) {
    state.renderChildren(node);
  },
  footer() {
    return;
  },
  ...proofHandlers,
};

class TypstSerializer implements ITypstSerializer {
  file: VFile;
  data: StateData;
  options: Options;
  handlers: Record<string, Handler>;
  footnotes: Record<string, FootnoteDefinition>;

  constructor(file: VFile, tree: Root, opts?: Options) {
    file.result = '';
    this.file = file;
    const { math, ...otherOpts } = opts ?? {};
    this.options = { ...otherOpts };
    if (math) this.options.math = resolveRecursiveCommands(math);
    this.data = { mathPlugins: {}, macros: new Set() };
    this.handlers = opts?.handlers ?? handlers;
    this.footnotes = Object.fromEntries(
      selectAll('footnoteDefinition', tree).map((node) => {
        const fn = node as FootnoteDefinition;
        return [fn.identifier, fn];
      }),
    );
    this.renderChildren(tree);
  }

  get out(): string {
    return this.file.result as string;
  }

  useMacro(macro: string) {
    this.data.macros.add(macro);
  }

  write(value: string) {
    this.file.result += value;
  }

  text(value: string, mathMode = false) {
    const escaped = mathMode ? stringToTypstMath(value) : stringToTypstText(value);
    this.write(escaped);
  }

  trimEnd() {
    this.file.result = this.out.trimEnd();
  }

  addNewLine() {
    this.write('\n');
  }

  ensureNewLine(trim = false) {
    if (trim) this.trimEnd();
    if (this.out.endsWith('\n')) return;
    this.addNewLine();
  }

  renderChildren(
    node: Partial<Parent> | Parent[],
    trailingNewLines = 0,
    opts: RenderChildrenOptions = {},
  ) {
    if (Array.isArray(node)) {
      this.renderChildren({ children: node }, trailingNewLines, opts);
      return;
    }
    const { delim = '', trimEnd = true, after } = opts;
    const numChildren = node.children?.length ?? 0;
    node.children?.forEach((child, index) => {
      if (!child) return;
      const handler = this.handlers[child?.type];
      if (handler) {
        handler(child, this, node);
      } else {
        fileError(this.file, `Unhandled Typst conversion for node of "${child?.type}"`, {
          node: child,
          source: 'myst-to-typst',
        });
      }
      if (delim && index + 1 < numChildren) this.write(delim);
    });
    if (trimEnd) this.trimEnd();
    if (after) this.write(after);
    for (let i = trailingNewLines; i--; ) this.addNewLine();
  }

  renderEnvironment(node: any, env: string) {
    this.file.result += `#${env}[\n`;
    this.renderChildren(node, 1);
    this.file.result += `]\n\n`;
  }

  renderInlineEnvironment(node: any, env: string) {
    this.file.result += `#${env}[`;
    this.renderChildren(node);
    this.file.result += ']';
  }
}

const plugin: Plugin<[Options?], Root, VFile> = function (opts) {
  this.Compiler = (node, file) => {
    const state = new TypstSerializer(file, node, opts ?? { handlers });
    const tex = (file.result as string).trim();
    const result: TypstResult = {
      macros: [...state.data.macros],
      commands: state.data.mathPlugins,
      value: tex,
    };
    file.result = result;
    return file;
  };

  return (node: Root) => {
    // Preprocess
    return node;
  };
};

export default plugin;
