import React, {
  type JSX,
  ComponentPropsWithoutRef,
  ReactEventHandler,
  Suspense,
  lazy,
  useMemo,
  useState,
} from 'react';
import {
  DOMNode,
  Element,
  Text as DOMText,
  HTMLReactParserOptions,
  attributesToProps,
  domToReact,
} from 'html-react-parser';
import { MatrixClient } from 'matrix-js-sdk';
import classNames from 'classnames';
import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
import Linkify from 'linkify-react';
import { ErrorBoundary } from 'react-error-boundary';
import { ChildNode } from 'domhandler';
import * as css from '../styles/CustomHtml.css';
import {
  getMxIdLocalPart,
  getCanonicalAliasRoomId,
  isRoomAlias,
  mxcUrlToHttp,
} from '../utils/matrix';
import { getMemberDisplayName } from '../utils/room';
import { EMOJI_PATTERN, sanitizeForRegex, URL_NEG_LB } from '../utils/regex';
import { getHexcodeForEmoji, getShortcodeFor } from './emoji';
import { findAndReplace } from '../utils/findAndReplace';
import {
  parseMatrixToRoom,
  parseMatrixToRoomEvent,
  parseMatrixToUser,
  testMatrixTo,
} from './matrix-to';
import { onEnterOrSpace } from '../utils/keyboard';
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
import { tokenize, tokenStyle } from '../utils/syntaxHighlight';
import { splitMathSegments } from '../utils/mathParse';

const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));

// KaTeX (and its CSS) is heavy, so it is code-split behind this dynamic import
// and is NOT part of the eager import graph — see src/app/components/math/KaTeX.tsx.
const KaTeXMath = lazy(() => import('../components/math/KaTeX'));

/** Languages handled by the custom TDS tokenizer. */
const TDS_TOKENIZER_LANGS = new Set([
  'js',
  'javascript',
  'ts',
  'typescript',
  'jsx',
  'tsx',
  'py',
  'python',
  'rs',
  'rust',
]);

/**
 * Renders a code string as an array of coloured <span> elements using the
 * lightweight TDS tokenizer. Falls back to a plain text node when the
 * language is not in the supported set.
 */
function renderTokenizedCode(code: string, lang: string): React.ReactNode {
  const normalised = lang.toLowerCase().replace(/^language-/, '');
  if (!TDS_TOKENIZER_LANGS.has(normalised)) return code;

  const tokens = tokenize(code, normalised);

  return tokens.map((tok, idx) => (
    <span key={idx} style={tok.type !== 'plain' ? tokenStyle(tok.type) : undefined}>
      {tok.text}
    </span>
  ));
}

/**
 * Renders LaTeX via the lazily-loaded KaTeX component.
 *
 * `suspenseFallback` is shown while the KaTeX chunk loads (the raw LaTeX text).
 * `errorFallback` is shown if rendering fails outright — for the spec
 * `data-mx-maths` path this is the element's original children (the spec
 * fallback content); for the plain-text `$…$` path it is the raw source.
 */
const renderMath = (
  latex: string,
  displayMode: boolean,
  suspenseFallback: React.ReactNode,
  errorFallback: React.ReactNode,
): JSX.Element => (
  <ErrorBoundary fallback={<>{errorFallback}</>}>
    <Suspense fallback={<>{suspenseFallback}</>}>
      <KaTeXMath latex={latex} displayMode={displayMode} />
    </Suspense>
  </ErrorBoundary>
);

const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');

export const LINKIFY_OPTS: LinkifyOpts = {
  attributes: {
    target: '_blank',
    rel: 'noreferrer noopener',
  },
  validate: {
    url: (value) => /^(https?|ftp|mailto|magnet):/.test(value),
  },
  ignoreTags: ['span'],
};

export const makeMentionCustomProps = (
  handleMentionClick?: ReactEventHandler<HTMLElement>,
  content?: string,
): ComponentPropsWithoutRef<'a'> => ({
  style: { cursor: 'pointer' },
  target: '_blank',
  rel: 'noreferrer noopener',
  role: 'link',
  tabIndex: handleMentionClick ? 0 : -1,
  onKeyDown: handleMentionClick ? onEnterOrSpace(handleMentionClick) : undefined,
  onClick: handleMentionClick,
  children: content,
});

export const renderMatrixMention = (
  mx: MatrixClient,
  currentRoomId: string | undefined,
  href: string,
  customProps: ComponentPropsWithoutRef<'a'>,
) => {
  const userId = parseMatrixToUser(href);
  if (userId) {
    const currentRoom = mx.getRoom(currentRoomId);

    return (
      <a
        href={href}
        {...customProps}
        className={css.Mention({ highlight: mx.getUserId() === userId })}
        data-mention-id={userId}
      >
        {`@${
          (currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId)
        }`}
      </a>
    );
  }

  const matrixToRoom = parseMatrixToRoom(href);
  if (matrixToRoom) {
    const { roomIdOrAlias, viaServers } = matrixToRoom;
    const mentionRoom = mx.getRoom(
      isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias,
    );

    const fallbackContent = mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias;

    return (
      <a
        href={href}
        {...customProps}
        className={css.Mention({
          highlight: currentRoomId === (mentionRoom?.roomId ?? roomIdOrAlias),
        })}
        data-mention-id={mentionRoom?.roomId ?? roomIdOrAlias}
        data-mention-via={viaServers?.join(',')}
      >
        {customProps.children ? customProps.children : fallbackContent}
      </a>
    );
  }

  const matrixToRoomEvent = parseMatrixToRoomEvent(href);
  if (matrixToRoomEvent) {
    const { roomIdOrAlias, eventId, viaServers } = matrixToRoomEvent;
    const mentionRoom = mx.getRoom(
      isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias,
    );

    return (
      <a
        href={href}
        {...customProps}
        className={css.Mention({
          highlight: currentRoomId === (mentionRoom?.roomId ?? roomIdOrAlias),
        })}
        data-mention-id={mentionRoom?.roomId ?? roomIdOrAlias}
        data-mention-event-id={eventId}
        data-mention-via={viaServers?.join(',')}
      >
        {customProps.children
          ? customProps.children
          : `Message: ${mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias}`}
      </a>
    );
  }

  return undefined;
};

export const factoryRenderLinkifyWithMention = (
  mentionRender: (href: string) => JSX.Element | undefined,
): OptFn<(ir: IntermediateRepresentation) => any> => {
  const render: OptFn<(ir: IntermediateRepresentation) => any> = ({
    tagName,
    attributes,
    content,
  }) => {
    if (tagName === 'a' && testMatrixTo(tryDecodeURIComponent(attributes.href))) {
      const mention = mentionRender(tryDecodeURIComponent(attributes.href));
      if (mention) return mention;
    }

    return <a {...attributes}>{content}</a>;
  };
  return render;
};

export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
  findAndReplace(
    text,
    EMOJI_REG_G,
    (match, pushIndex) => {
      const shortcode = getShortcodeFor(getHexcodeForEmoji(match[0]));
      return (
        <span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
          <span
            className={css.Emoticon()}
            title={shortcode}
            aria-label={shortcode || undefined}
            role={shortcode ? 'img' : undefined}
          >
            {match[0]}
          </span>
        </span>
      );
    },
    (txt) => txt,
  );

export const makeHighlightRegex = (highlights: string[]): RegExp | undefined => {
  const pattern = highlights.map(sanitizeForRegex).join('|');
  if (!pattern) return undefined;
  return new RegExp(pattern, 'gi');
};

export const highlightText = (
  regex: RegExp,
  data: (string | JSX.Element)[],
): (string | JSX.Element)[] =>
  data.flatMap((text) => {
    if (typeof text !== 'string') return text;

    return findAndReplace(
      text,
      regex,
      (match, pushIndex) => (
        <span key={`highlight-${pushIndex}`} className={css.highlightText}>
          {match[0]}
        </span>
      ),
      (txt) => txt,
    );
  });

/**
 * Recursively extracts and concatenates all text content from an array of ChildNode objects.
 *
 * @param {ChildNode[]} nodes - An array of ChildNode objects to extract text from.
 * @returns {string} The concatenated plain text content of all descendant text nodes.
 */
const extractTextFromChildren = (nodes: ChildNode[]): string => {
  let text = '';

  nodes.forEach((node) => {
    if (node.type === 'text') {
      text += node.data;
    } else if (node instanceof Element && node.children) {
      text += extractTextFromChildren(node.children);
    }
  });

  return text;
};

export function CodeBlock({
  children,
  opts,
}: {
  children: ChildNode[];
  opts: HTMLReactParserOptions;
}) {
  const code = children[0];
  const attribs = code instanceof Element && code.name === 'code' ? code.attribs : undefined;
  const languageClass = attribs?.class;
  const customLabel = attribs?.['data-label'];
  const language =
    languageClass && languageClass.startsWith('language-')
      ? languageClass.replace('language-', '')
      : languageClass;

  const LINE_LIMIT = 14;
  const largeCodeBlock = useMemo(
    () => extractTextFromChildren(children).split('\n').length > LINE_LIMIT,
    [children],
  );

  const [expanded, setExpand] = useState(false);
  const [copied, setCopied] = useTimeoutToggle();

  const handleCopy = () => {
    copyToClipboard(extractTextFromChildren(children));
    setCopied();
  };

  const toggleExpand = () => {
    setExpand(!expanded);
  };

  return (
    <Text size="T300" as="pre" className={css.CodeBlock}>
      <Header variant="Surface" size="400" className={css.CodeBlockHeader}>
        <Box grow="Yes">
          <Text size="L400" truncate>
            {customLabel ?? language ?? 'Code'}
          </Text>
        </Box>
        <Box shrink="No" gap="200">
          <Chip
            variant={copied ? 'Success' : 'Surface'}
            fill="None"
            radii="Pill"
            onClick={handleCopy}
            before={copied && <Icon size="50" src={Icons.Check} />}
          >
            <Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
          </Chip>
          {largeCodeBlock && (
            <IconButton
              size="300"
              variant="SurfaceVariant"
              outlined
              radii="300"
              onClick={toggleExpand}
              aria-label={expanded ? 'Collapse' : 'Expand'}
            >
              <Icon size="50" src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} />
            </IconButton>
          )}
        </Box>
      </Header>
      <Scroll
        style={{
          maxHeight: largeCodeBlock && !expanded ? toRem(300) : undefined,
          paddingBottom: largeCodeBlock ? config.space.S400 : undefined,
        }}
        direction="Both"
        variant="SurfaceVariant"
        size="300"
        visibility="Hover"
        hideTrack
      >
        <div id="code-block-content" className={css.CodeBlockInternal}>
          {domToReact(children as unknown as DOMNode[], opts)}
        </div>
      </Scroll>
      {largeCodeBlock && !expanded && <Box className={css.CodeBlockBottomShadow} />}
    </Text>
  );
}

export const getReactCustomHtmlParser = (
  mx: MatrixClient,
  roomId: string | undefined,
  params: {
    linkifyOpts: LinkifyOpts;
    highlightRegex?: RegExp;
    handleSpoilerClick?: ReactEventHandler<HTMLElement>;
    handleMentionClick?: ReactEventHandler<HTMLElement>;
    useAuthentication?: boolean;
  },
): HTMLReactParserOptions => {
  const opts: HTMLReactParserOptions = {
    replace: (domNode) => {
      if (domNode instanceof Element && 'name' in domNode) {
        const { name, attribs, children, parent } = domNode;
        const props = attributesToProps(attribs);

        if (name === 'h1') {
          return (
            <Text {...props} className={css.Heading} size="H2">
              {domToReact(children as unknown as DOMNode[], opts)}
            </Text>
          );
        }

        if (name === 'h2') {
          return (
            <Text {...props} className={css.Heading} size="H3">
              {domToReact(children as unknown as DOMNode[], opts)}
            </Text>
          );
        }

        if (name === 'h3') {
          return (
            <Text {...props} className={css.Heading} size="H4">
              {domToReact(children as unknown as DOMNode[], opts)}
            </Text>
          );
        }

        if (name === 'h4') {
          return (
            <Text {...props} className={css.Heading} size="H4">
              {domToReact(children as unknown as DOMNode[], opts)}
            </Text>
          );
        }

        if (name === 'h5') {
          return (
            <Text {...props} className={css.Heading} size="H5">
              {domToReact(children as unknown as DOMNode[], opts)}
            </Text>
          );
        }

        if (name === 'h6') {
          return (
            <Text {...props} className={css.Heading} size="H6">
              {domToReact(children as unknown as DOMNode[], opts)}
            </Text>
          );
        }

        if (name === 'p') {
          return (
            <Text {...props} className={classNames(css.Paragraph, css.MarginSpaced)} size="Inherit">
              {domToReact(children as unknown as DOMNode[], opts)}
            </Text>
          );
        }

        if (name === 'pre') {
          return <CodeBlock opts={opts}>{children}</CodeBlock>;
        }

        if (name === 'table') {
          // Sanitize allows tables, but a wide one would otherwise overflow the
          // message column and the page body on narrow screens. Wrap it in a
          // horizontally-scrollable container (same idea as CodeBlock's Scroll).
          return (
            <div style={{ overflowX: 'auto', maxWidth: '100%' }}>
              <table {...props}>{domToReact(children as unknown as DOMNode[], opts)}</table>
            </div>
          );
        }

        if (name === 'blockquote') {
          return (
            <Text {...props} size="Inherit" as="blockquote" className={css.BlockQuote}>
              {domToReact(children as unknown as DOMNode[], opts)}
            </Text>
          );
        }

        if (name === 'ul') {
          return (
            <ul {...props} className={css.List}>
              {domToReact(children as unknown as DOMNode[], opts)}
            </ul>
          );
        }
        if (name === 'ol') {
          return (
            <ol {...props} className={css.List}>
              {domToReact(children as unknown as DOMNode[], opts)}
            </ol>
          );
        }

        if (name === 'code') {
          if (parent && 'name' in parent && parent.name === 'pre') {
            const codeReact = domToReact(children as unknown as DOMNode[], opts);
            if (typeof codeReact === 'string') {
              let lang: string | undefined =
                typeof props.className === 'string' ? props.className : undefined;
              if (lang === 'language-rs') lang = 'language-rust';
              else if (lang === 'language-js') lang = 'language-javascript';
              else if (lang === 'language-ts') lang = 'language-typescript';

              // Use lightweight TDS tokenizer for supported languages to render
              // coloured <span> elements with inline TDS CSS variable styles.
              const normLang = (lang ?? '').toLowerCase().replace(/^language-/, '');
              if (TDS_TOKENIZER_LANGS.has(normLang)) {
                return (
                  <code {...props} className={lang}>
                    {renderTokenizedCode(codeReact, normLang)}
                  </code>
                );
              }

              return (
                <ErrorBoundary fallback={<code {...props}>{codeReact}</code>}>
                  <Suspense fallback={<code {...props}>{codeReact}</code>}>
                    <ReactPrism>
                      {(ref) => (
                        <code ref={ref} {...props} className={lang}>
                          {codeReact}
                        </code>
                      )}
                    </ReactPrism>
                  </Suspense>
                </ErrorBoundary>
              );
            }
          } else {
            return (
              <Text as="code" size="T300" className={css.Code} {...props}>
                {domToReact(children as unknown as DOMNode[], opts)}
              </Text>
            );
          }
        }

        if (name === 'a' && testMatrixTo(tryDecodeURIComponent(String(props.href)))) {
          const content = children.find((child) => !(child instanceof DOMText))
            ? undefined
            : children.map((c) => (c instanceof DOMText ? c.data : '')).join();

          const mention = renderMatrixMention(
            mx,
            roomId,
            tryDecodeURIComponent(String(props.href)),
            makeMentionCustomProps(params.handleMentionClick, content),
          );

          if (mention) return mention;
        }

        if ((name === 'span' || name === 'div') && 'data-mx-maths' in props) {
          // Spec (CS-API §11.5): render the `data-mx-maths` LaTeX with KaTeX
          // (block for <div>, inline for <span>). On failure fall back to the
          // element's existing children, which the spec defines as the fallback
          // representation.
          const latex = String(props['data-mx-maths']);
          const displayMode = name === 'div';
          const fallback = displayMode ? (
            <div {...props}>{domToReact(children as unknown as DOMNode[], opts)}</div>
          ) : (
            <span {...props}>{domToReact(children as unknown as DOMNode[], opts)}</span>
          );
          return renderMath(latex, displayMode, latex, fallback);
        }

        if (name === 'span' && 'data-mx-spoiler' in props) {
          return (
            <span
              {...props}
              role="button"
              tabIndex={0}
              onKeyDown={(e) => {
                if (e.key === 'Enter' || e.key === ' ') params.handleSpoilerClick?.(e as any);
              }}
              onClick={params.handleSpoilerClick}
              className={css.Spoiler()}
              aria-label="Spoiler — click to reveal"
              aria-pressed
              style={{ cursor: 'pointer' }}
            >
              {domToReact(children as unknown as DOMNode[], opts)}
            </span>
          );
        }

        if (name === 'img') {
          const htmlSrc = mxcUrlToHttp(mx, String(props.src), params.useAuthentication);
          if (htmlSrc && String(props.src).startsWith('mxc://') === false) {
            return (
              <a href={htmlSrc} target="_blank" rel="noreferrer noopener">
                {props.alt || props.title || htmlSrc}
              </a>
            );
          }
          if (htmlSrc && 'data-mx-emoticon' in props) {
            const emoticonAlt =
              (typeof props.alt === 'string' && props.alt) ||
              (typeof props.title === 'string' && props.title) ||
              'emoji';
            return (
              <span className={css.EmoticonBase}>
                <span className={css.Emoticon()}>
                  <img
                    {...props}
                    alt={emoticonAlt}
                    className={css.EmoticonImg}
                    src={htmlSrc}
                    loading="lazy"
                  />
                </span>
              </span>
            );
          }
          if (htmlSrc) return <img {...props} className={css.Img} src={htmlSrc} loading="lazy" />;
        }
      }

      if (domNode instanceof DOMText) {
        const parentName =
          domNode.parent && 'name' in domNode.parent ? domNode.parent.name : undefined;
        const linkify = parentName !== 'code' && parentName !== 'a';
        // Never parse `$…$`/`$$…$$` math inside <pre>/<code> (verbatim regions).
        const mathAllowed = parentName !== 'code' && parentName !== 'pre';

        const renderTextChunk = (text: string): (string | JSX.Element)[] | JSX.Element => {
          let jsx = scaleSystemEmoji(text);
          if (params.highlightRegex) {
            jsx = highlightText(params.highlightRegex, jsx);
          }
          if (linkify) {
            return <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
          }
          return jsx;
        };

        if (mathAllowed) {
          const segments = splitMathSegments(domNode.data);
          if (segments.some((segment) => segment.type !== 'text')) {
            return (
              <>
                {segments.map((segment, index) => {
                  if (segment.type === 'text') {
                    return (
                      <React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
                    );
                  }
                  const raw =
                    segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
                  return (
                    <React.Fragment key={index}>
                      {renderMath(segment.value, segment.type === 'block', raw, raw)}
                    </React.Fragment>
                  );
                })}
              </>
            );
          }
        }

        return renderTextChunk(domNode.data);
      }
      return undefined;
    },
  };
  return opts;
};
