import { ANY_CJK, pangu } from '../../shared/index.js';

const QUOTE = /["\u201c\u201d]/;

// Where the space goes at the boundary between two adjacent text nodes
export type BoundarySpacingDecision = 'none' | 'prepend-next' | 'append-current' | 'insert-element';

// What a single text node needs before its boundaries are considered
export type TextNodeSpacingDecision = 'trim-leading-space' | 'prepend-space' | 'apply-text-spacing';

export interface BoundarySpacingContext {
  // The last three characters, or a whole ASCII word ending in + or -, so a sliced word cannot look like a listed suffix
  currentTail: string;
  nextFirst: string;
  currentEndsWithSpace: boolean;
  nextStartsWithSpace: boolean;
  whitespaceBetween: boolean;
  // Collectable text sits between the nodes, so they are not adjacent and no
  // boundary exists (e.g. an unqueued sibling between two separately mutated
  // nodes). Content the engine never collects (ignored tags like <code>) does
  // not count, so spacing across those islands is preserved
  contentBetween: boolean;
  spaceLikeSiblingAfterCurrent: boolean;
  spaceLikeSiblingAfterCurrentBoundary: boolean;
  spaceLikeSiblingBeforeNext: boolean;
  spaceLikeSiblingBeforeNextBoundary: boolean;
  currentBoundaryIsBlock: boolean;
  currentBoundaryIsSpaceSensitive: boolean;
  nextBoundaryIsBlock: boolean;
  nextBoundaryIsIgnored: boolean;
  nextBoundaryIsSpaceSensitive: boolean;
  // These facts read computed styles, so they are supplied lazily and only
  // consulted for boundaries that survive the cheap checks
  hiddenBoundaryBefore: () => boolean;
  hiddenBoundaryAfter: () => boolean;
  inGridOrFlexContainer: () => boolean;
}

export interface TextNodeSpacingContext {
  text: string;
  previousElementLastChar: string | null;
  // Reads computed styles, so it is supplied lazily and only consulted when
  // the text node starts with a space
  hiddenBoundaryBefore: () => boolean;
}

export function decideBoundarySpacing(boundarySpacingContext: BoundarySpacingContext) {
  if (boundarySpacingContext.spaceLikeSiblingAfterCurrent) {
    return 'none';
  }

  if (boundarySpacingContext.currentEndsWithSpace || boundarySpacingContext.nextStartsWithSpace || boundarySpacingContext.whitespaceBetween) {
    return 'none';
  }

  if (boundarySpacingContext.contentBetween) {
    return 'none';
  }

  if (!needsBoundarySpace(boundarySpacingContext.currentTail, boundarySpacingContext.nextFirst)) {
    return 'none';
  }

  if (boundarySpacingContext.spaceLikeSiblingAfterCurrentBoundary || boundarySpacingContext.currentBoundaryIsBlock) {
    return 'none';
  }

  if (!boundarySpacingContext.nextBoundaryIsSpaceSensitive) {
    if (
      boundarySpacingContext.nextBoundaryIsIgnored ||
      boundarySpacingContext.nextBoundaryIsBlock ||
      boundarySpacingContext.spaceLikeSiblingBeforeNext ||
      boundarySpacingContext.hiddenBoundaryBefore()
    ) {
      return 'none';
    }
    return 'prepend-next';
  }

  if (!boundarySpacingContext.currentBoundaryIsSpaceSensitive) {
    if (boundarySpacingContext.hiddenBoundaryAfter()) {
      return 'none';
    }
    return 'append-current';
  }

  if (boundarySpacingContext.spaceLikeSiblingBeforeNextBoundary || boundarySpacingContext.hiddenBoundaryAfter()) {
    return 'none';
  }

  // Skip <pangu> element insertion in Grid/Flexbox containers
  // because the element becomes a layout item and breaks the layout
  if (boundarySpacingContext.inGridOrFlexContainer()) {
    return 'none';
  }

  return 'insert-element';
}

export function decideTextNodeSpacing(textNodeSpacingContext: TextNodeSpacingContext) {
  const textNodeSpacingDecisions: TextNodeSpacingDecision[] = [];

  // The standalone quote rule reads the text left by the trim rule
  let { text } = textNodeSpacingContext;
  if (text.startsWith(' ') && textNodeSpacingContext.hiddenBoundaryBefore()) {
    textNodeSpacingDecisions.push('trim-leading-space');
    text = text.substring(1);
  }

  if (isStandaloneQuote(text)) {
    if (textNodeSpacingContext.previousElementLastChar !== null && ANY_CJK.test(textNodeSpacingContext.previousElementLastChar)) {
      textNodeSpacingDecisions.push('prepend-space');
    }
  } else {
    textNodeSpacingDecisions.push('apply-text-spacing');
  }

  return textNodeSpacingDecisions;
}

// spaceJunction is pure and a page repeats the same few junction windows at
// every boundary, so the spaced junctions are memoized. The cap only guards
// pathological pages with unbounded unique windows
const spacedJunctionCache = new Map<string, string>();
const SPACED_JUNCTION_CACHE_MAX = 4096;

function spaceJunction(currentTail: string, nextFirst: string) {
  const junction = `${currentTail}${nextFirst}`;
  const cached = spacedJunctionCache.get(junction);
  if (cached !== undefined) {
    return cached;
  }

  const spacedJunction = pangu.spaceText(junction);

  if (spacedJunctionCache.size >= SPACED_JUNCTION_CACHE_MAX) {
    spacedJunctionCache.clear();
  }
  spacedJunctionCache.set(junction, spacedJunction);
  return spacedJunction;
}

function needsBoundarySpace(currentTail: string, nextFirst: string) {
  // Only a space right at the junction counts: a space that spaceText puts
  // anywhere else belongs inside the tail, not at the boundary
  return spaceJunction(currentTail, nextFirst).endsWith(` ${nextFirst}`) && !isQuoteNextToCjk(currentTail.slice(-1), nextFirst);
}

// The junction reading can put a second space inside the tail itself: CJK| + CJK reads CJK | CJK, because pipe reading needs both sides of the pipe in view. Returns the tail with its interior
// spaces written in, or null when the tail already reads right. Only meaningful when the boundary decision is a spacing action; on 'none' the tail must stay untouched
export function respaceCurrentTail(currentTail: string, nextFirst: string) {
  const spacedJunction = spaceJunction(currentTail, nextFirst);
  if (!spacedJunction.endsWith(` ${nextFirst}`)) {
    return null;
  }
  const spacedTail = spacedJunction.slice(0, -2);
  return spacedTail === currentTail ? null : spacedTail;
}

function isQuoteNextToCjk(currentLast: string, nextFirst: string) {
  return (QUOTE.test(currentLast) && ANY_CJK.test(nextFirst)) || (ANY_CJK.test(currentLast) && /["\u201d]/.test(nextFirst));
}

function isStandaloneQuote(text: string) {
  return text.length === 1 && QUOTE.test(text);
}
