import type {
  CaptureDefinition,
  ReplacementSyntaxResult,
  ReplacementToken,
} from "../../../model/syntax";
import type { RegexDiagnostic } from "../../../model/diagnostics";
import {
  DEFAULT_REGEX_LIMITS,
  TEMPLATE_PRESENTATION_LIMITS,
} from "../../../execution/request-limits";

const MAXIMUM_LITERAL_EXPLANATION_UTF16 = 160;

function literalExplanation(raw: string): string {
  const truncated = raw.length > MAXIMUM_LITERAL_EXPLANATION_UTF16;
  const preview = truncated
    ? `${raw.slice(0, MAXIMUM_LITERAL_EXPLANATION_UTF16)}…`
    : raw;
  return `Insert literal text ${JSON.stringify(preview)}${
    truncated ? ` (${raw.length.toLocaleString()} UTF-16 units total)` : ""
  }`;
}

function token(
  index: number,
  kind: ReplacementToken["kind"],
  raw: string,
  start: number,
  explanation: string,
  capture?: { number?: number; name?: string },
): ReplacementToken {
  return {
    id: `replacement-${index}`,
    kind,
    raw,
    range: { startUtf16: start, endUtf16: start + raw.length },
    explanation,
    ...(capture?.number === undefined ? {} : { captureNumber: capture.number }),
    ...(capture?.name === undefined ? {} : { captureName: capture.name }),
  };
}

export function parseEcmaScriptReplacement(
  replacement: string,
  captures: readonly CaptureDefinition[],
): ReplacementSyntaxResult {
  if (
    replacement.length > DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
  ) {
    return {
      accepted: false,
      tokens: [],
      diagnostics: [
        {
          id: "replacement-template-input-limit",
          source: "syntax-provider",
          severity: "error",
          code: "replacement-template-limit",
          message: `Replacement template exceeds the ${DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16.toLocaleString()} UTF-16 unit limit; it was not parsed or executed.`,
          flavour: "ecmascript",
          provenance: "derived",
        },
      ],
      totalDiagnostics: 1,
      diagnosticsTruncated: false,
    };
  }
  const tokens: ReplacementToken[] = [];
  const diagnostics: RegexDiagnostic[] = [];
  let totalDiagnostics = 0;
  const byNumber = new Map(
    captures.map((capture) => [capture.number, capture]),
  );
  const byName = new Map(
    captures
      .filter(
        (capture): capture is CaptureDefinition & { name: string } =>
          capture.name !== undefined,
      )
      .map((capture) => [capture.name, capture]),
  );
  const hasNamedCaptures = byName.size > 0;
  let cursor = 0;
  let literalStart = 0;

  const addDiagnostic = (diagnostic: RegexDiagnostic) => {
    totalDiagnostics += 1;
    if (
      diagnostics.length < TEMPLATE_PRESENTATION_LIMITS.replacementDiagnostics
    ) {
      diagnostics.push(diagnostic);
    }
  };

  const flushLiteral = (end: number) => {
    if (end <= literalStart) return;
    const raw = replacement.slice(literalStart, end);
    tokens.push(
      token(
        tokens.length,
        "literal",
        raw,
        literalStart,
        literalExplanation(raw),
      ),
    );
  };

  while (cursor < replacement.length) {
    if (replacement[cursor] !== "$" || cursor + 1 >= replacement.length) {
      cursor += 1;
      continue;
    }
    const next = replacement[cursor + 1] ?? "";
    let raw: string | undefined;
    let replacementToken: ReplacementToken | undefined;
    if (next === "$") {
      raw = "$$";
      replacementToken = token(
        tokens.length,
        "escaped-dollar",
        raw,
        cursor,
        "Insert one literal dollar sign",
      );
    } else if (next === "&") {
      raw = "$&";
      replacementToken = token(
        tokens.length,
        "full-match",
        raw,
        cursor,
        "Insert the complete match",
      );
    } else if (next === "`") {
      raw = "$`";
      replacementToken = token(
        tokens.length,
        "prefix",
        raw,
        cursor,
        "Insert the subject text before this match",
      );
    } else if (next === "'") {
      raw = "$'";
      replacementToken = token(
        tokens.length,
        "suffix",
        raw,
        cursor,
        "Insert the subject text after this match",
      );
    } else if (next === "<") {
      const end = replacement.indexOf(">", cursor + 2);
      if (end >= 0 && hasNamedCaptures) {
        raw = replacement.slice(cursor, end + 1);
        const name = replacement.slice(cursor + 2, end);
        const definition = byName.get(name);
        replacementToken = token(
          tokens.length,
          "named-capture",
          raw,
          cursor,
          definition
            ? `Insert named capture “${name}”`
            : `No capture named “${name}” is defined; ECMAScript inserts empty text`,
          { name },
        );
        if (!definition) {
          addDiagnostic({
            id: `replacement-missing-name-${cursor}`,
            source: "syntax-provider",
            severity: "warning",
            code: "replacement-unknown-capture",
            message: `Replacement references unknown named capture “${name}”.`,
            range: { startUtf16: cursor, endUtf16: end + 1 },
            flavour: "ecmascript",
            provenance: "derived",
          });
        }
      }
    } else if (/\d/u.test(next)) {
      const second = replacement[cursor + 2] ?? "";
      const twoDigit = /\d/u.test(second)
        ? Number.parseInt(`${next}${second}`, 10)
        : undefined;
      const oneDigit = Number.parseInt(next, 10);
      const number = byNumber.has(twoDigit ?? -1)
        ? twoDigit
        : byNumber.has(oneDigit)
          ? oneDigit
          : undefined;
      if (number === undefined || number === 0) {
        cursor += 1;
        continue;
      }
      const consumesTwoDigits = twoDigit === number;
      raw = replacement.slice(cursor, cursor + (consumesTwoDigits ? 3 : 2));
      replacementToken = token(
        tokens.length,
        "numbered-capture",
        raw,
        cursor,
        `Insert numbered capture ${number}`,
        { number },
      );
    }

    if (!raw || !replacementToken) {
      cursor += 1;
      continue;
    }
    flushLiteral(cursor);
    tokens.push({
      ...replacementToken,
      id: `replacement-${tokens.length}`,
    });
    cursor += raw.length;
    literalStart = cursor;
  }
  flushLiteral(replacement.length);
  const diagnosticsTruncated = totalDiagnostics > diagnostics.length;
  if (
    diagnosticsTruncated &&
    TEMPLATE_PRESENTATION_LIMITS.replacementDiagnostics > 0
  ) {
    const retainedDetails =
      TEMPLATE_PRESENTATION_LIMITS.replacementDiagnostics - 1;
    const omitted = totalDiagnostics - retainedDetails;
    diagnostics.splice(retainedDetails, diagnostics.length - retainedDetails, {
      id: "replacement-diagnostic-render-limit",
      source: "syntax-provider",
      severity: "warning",
      code: "replacement-diagnostic-limit",
      message: `${omitted.toLocaleString()} additional replacement diagnostics were not materialized; the template contains ${totalDiagnostics.toLocaleString()} diagnostics in total.`,
      flavour: "ecmascript",
      provenance: "derived",
    });
  }
  return {
    accepted: true,
    tokens,
    diagnostics,
    totalDiagnostics,
    diagnosticsTruncated,
  };
}
