import type { RegexEngineAdapter } from "../../RegexEngineAdapter";
import type {
  CaptureResult,
  RegexExecutionFlags,
  RegexExecutionRequest,
  RegexExecutionResult,
  RegexMatchResult,
  RegexReplacementRequest,
  RegexReplacementResult,
} from "../../../model/match";
import type { RegexEngineInfo } from "../../../model/flavour";
import type { RegexDiagnostic } from "../../../model/diagnostics";
import { DEFAULT_REGEX_LIMITS, utf8ByteLength } from "../../request-limits";
import { APPLICATION_VERSION } from "../../../../version";

export const ECMASCRIPT_FLAVOUR_VERSION =
  "ECMAScript 2025 syntax / current browser runtime";
const ECMASCRIPT_COMPATIBLE_FLAVOUR_VERSIONS = new Set([
  ECMASCRIPT_FLAVOUR_VERSION,
  "ECMAScript 2025",
  "2025",
]);

const FLAG_ORDER = "dgimsuvy";
const CAPTURE_VALUE_PREVIEW_UTF16 = 64 * 1024;
const TOTAL_VALUE_PREVIEW_UTF16 = 8 * 1024 * 1024;

class ValuePreviewBudget {
  #remaining = TOTAL_VALUE_PREVIEW_UTF16;
  truncated = false;

  take(value: string): { readonly value: string; readonly truncated: boolean } {
    const maximum = Math.min(CAPTURE_VALUE_PREVIEW_UTF16, this.#remaining);
    const preview = value.slice(0, maximum);
    this.#remaining -= preview.length;
    const truncated = preview.length !== value.length;
    this.truncated ||= truncated;
    return { value: preview, truncated };
  }
}

function hasIndicesSupport(): boolean {
  try {
    return new RegExp("", "d").hasIndices;
  } catch {
    return false;
  }
}

function runtimeIdentity(): string {
  return typeof navigator === "undefined"
    ? "Unknown ECMAScript runtime"
    : navigator.userAgent;
}

function engineInfo(): RegexEngineInfo {
  const identity = runtimeIdentity();
  return {
    flavour: "ecmascript",
    adapterVersion: APPLICATION_VERSION,
    engineName: "Native ECMAScript RegExp",
    engineVersion: identity,
    runtimeVersion: identity,
    offsetUnit: "utf16",
    capabilities: {
      compilation: true,
      matching: true,
      replacement: true,
      namedCaptures: true,
      captureHistory: false,
      actualTrace: false,
      benchmark: true,
    },
  };
}

function normalizedFlags(
  userFlags: readonly string[],
  scanAll: boolean,
): RegexExecutionFlags {
  const unique = new Set(userFlags);
  const user = FLAG_ORDER.split("")
    .filter((flag) => unique.has(flag))
    .join("");
  const addIndices = hasIndicesSupport() && !unique.has("d");
  const addGlobal = scanAll && !unique.has("g") && !unique.has("y");
  if (addIndices) unique.add("d");
  if (addGlobal) unique.add("g");
  return {
    userFlags: user,
    effectiveFlags: FLAG_ORDER.split("")
      .filter((flag) => unique.has(flag))
      .join(""),
    internallyAddedIndicesFlag: addIndices,
    internallyAddedGlobalFlag: addGlobal,
  };
}

function advanceStringIndex(
  subject: string,
  index: number,
  unicode: boolean,
): number {
  if (!unicode) return index + 1;
  const first = subject.charCodeAt(index);
  if (first < 0xd800 || first > 0xdbff || index + 1 >= subject.length) {
    return index + 1;
  }
  const second = subject.charCodeAt(index + 1);
  return second >= 0xdc00 && second <= 0xdfff ? index + 2 : index + 1;
}

function captureResult(
  match: RegExpExecArray,
  groupNumber: number,
  name: string | undefined,
  previews: ValuePreviewBudget,
): CaptureResult {
  const value = match[groupNumber];
  const indices = match.indices?.[groupNumber];
  if (value === undefined) {
    return {
      groupNumber,
      ...(name ? { groupName: name } : {}),
      status: "did-not-participate",
    };
  }
  const preview = previews.take(value);
  if (!indices) {
    return {
      groupNumber,
      ...(name ? { groupName: name } : {}),
      value: preview.value,
      status: preview.truncated
        ? "truncated"
        : value.length === 0
          ? "matched-empty"
          : "unavailable",
    };
  }
  const [start, end] = indices;
  return {
    groupNumber,
    ...(name ? { groupName: name } : {}),
    value: preview.value,
    status: preview.truncated
      ? "truncated"
      : value.length === 0
        ? "matched-empty"
        : "participated",
    range: { startUtf16: start, endUtf16: end },
    nativeRange: { start, end, unit: "utf16" },
  };
}

function matchResult(
  match: RegExpExecArray,
  matchNumber: number,
  request: RegexExecutionRequest,
  previews: ValuePreviewBudget,
): RegexMatchResult {
  const indices = match.indices?.[0];
  const start = indices?.[0] ?? match.index;
  const end = indices?.[1] ?? match.index + match[0].length;
  const names = new Map(
    request.captureMetadata.map((capture) => [capture.number, capture.name]),
  );
  const preview = previews.take(match[0]);
  const captures: CaptureResult[] = [];
  for (let groupNumber = 1; groupNumber < match.length; groupNumber += 1) {
    captures.push(
      captureResult(match, groupNumber, names.get(groupNumber), previews),
    );
  }
  return {
    matchNumber,
    value: preview.value,
    valueStatus: preview.truncated ? "truncated" : "complete",
    range: { startUtf16: start, endUtf16: end },
    nativeRange: { start, end, unit: "utf16" },
    captures,
  };
}

function compileDiagnostic(error: unknown): RegexDiagnostic {
  return {
    id: "ecmascript-engine-compile-error",
    source: "execution-engine",
    severity: "error",
    code: "compile-error",
    message:
      error instanceof Error
        ? error.message
        : "The native ECMAScript engine rejected the pattern.",
    flavour: "ecmascript",
    provenance: "reported",
  };
}

function emptyExecution(
  flags: RegexExecutionFlags,
  start: number,
  error: unknown,
): RegexExecutionResult {
  return {
    accepted: false,
    engine: engineInfo(),
    flags,
    matches: [],
    diagnostics: [compileDiagnostic(error)],
    elapsedMs: performance.now() - start,
    truncated: false,
  };
}

function validateRequest(
  request: RegexExecutionRequest,
  replacementRequest?: RegexReplacementRequest,
): void {
  if (request.flavour !== "ecmascript") {
    throw new Error(`ECMAScript adapter cannot execute ${request.flavour}.`);
  }
  if (
    request.flavourVersion !== undefined &&
    !ECMASCRIPT_COMPATIBLE_FLAVOUR_VERSIONS.has(request.flavourVersion)
  ) {
    throw new RangeError(
      `ECMAScript version ${JSON.stringify(request.flavourVersion)} is unsupported by this engine.`,
    );
  }
  if (
    new Set(request.flags).size !== request.flags.length ||
    request.flags.some(
      (flag) => flag.length !== 1 || !FLAG_ORDER.includes(flag),
    )
  ) {
    throw new RangeError("The request contains unsupported ECMAScript flags.");
  }
  if (request.flags.includes("u") && request.flags.includes("v")) {
    throw new RangeError("ECMAScript flags u and v cannot be combined.");
  }
  if (request.options && Object.keys(request.options).length > 0) {
    throw new RangeError(
      "The ECMAScript engine does not accept engine options.",
    );
  }
  if (request.pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
    throw new RangeError(
      `ECMAScript patterns are limited to ${DEFAULT_REGEX_LIMITS.patternHardLengthUtf16.toLocaleString()} UTF-16 units.`,
    );
  }
  if (
    utf8ByteLength(request.subject) >
    DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
  ) {
    throw new RangeError("ECMAScript subjects are limited to 16 MiB of UTF-8.");
  }
  if (
    !Number.isSafeInteger(request.maximumMatches) ||
    request.maximumMatches < 1 ||
    request.maximumMatches > DEFAULT_REGEX_LIMITS.maximumMatches ||
    !Number.isSafeInteger(request.maximumCaptureRows) ||
    request.maximumCaptureRows < 1 ||
    request.maximumCaptureRows > DEFAULT_REGEX_LIMITS.maximumCaptureRows
  ) {
    throw new RangeError(
      "ECMAScript result limits are outside the bounded contract.",
    );
  }
  if (
    replacementRequest &&
    (!Number.isSafeInteger(replacementRequest.maximumOutputBytes) ||
      replacementRequest.maximumOutputBytes < 1 ||
      replacementRequest.maximumOutputBytes >
        DEFAULT_REGEX_LIMITS.maximumReplacementOutputBytes)
  ) {
    throw new RangeError(
      "ECMAScript replacement output limits are outside the bounded contract.",
    );
  }
  if (
    replacementRequest &&
    replacementRequest.replacement.length >
      DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
  ) {
    throw new RangeError(
      `Replacement template exceeds the ${DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16.toLocaleString()} UTF-16 unit limit.`,
    );
  }
}

function executeEcmaScriptInternal(
  request: RegexExecutionRequest,
  onMatch?: (match: RegExpExecArray) => void,
): RegexExecutionResult {
  validateRequest(request);
  const start = performance.now();
  const flags = normalizedFlags(request.flags, request.scanAll);
  let expression: RegExp;
  try {
    expression = new RegExp(request.pattern, flags.effectiveFlags);
  } catch (error) {
    return emptyExecution(flags, start, error);
  }
  const iterate = flags.effectiveFlags.includes("g") || request.scanAll;
  const unicode =
    flags.effectiveFlags.includes("u") || flags.effectiveFlags.includes("v");
  const matches: RegexMatchResult[] = [];
  const previews = new ValuePreviewBudget();
  let captureRows = 0;
  let truncated = false;
  expression.lastIndex = 0;

  while (true) {
    const match = expression.exec(request.subject);
    if (!match) break;
    const rowsForMatch = Math.max(1, match.length - 1);
    if (
      matches.length >= request.maximumMatches ||
      captureRows + rowsForMatch > request.maximumCaptureRows
    ) {
      truncated = true;
      break;
    }
    matches.push(matchResult(match, matches.length + 1, request, previews));
    onMatch?.(match);
    captureRows += rowsForMatch;
    if (!iterate) break;
    if (match[0].length === 0) {
      expression.lastIndex = advanceStringIndex(
        request.subject,
        expression.lastIndex,
        unicode,
      );
      if (expression.lastIndex > request.subject.length) break;
    }
  }

  const diagnostics: RegexDiagnostic[] = [];
  if (!hasIndicesSupport()) {
    diagnostics.push({
      id: "ecmascript-indices-unavailable",
      source: "execution-engine",
      severity: "warning",
      code: "capture-indices-unavailable",
      message:
        "This browser does not support the ECMAScript indices flag; exact capture ranges are unavailable.",
      flavour: "ecmascript",
      provenance: "reported",
    });
  }
  if (truncated) {
    diagnostics.push({
      id: "ecmascript-results-truncated",
      source: "execution-engine",
      severity: "warning",
      code: "result-limit",
      message: `Results reached the configured limit (${request.maximumMatches} matches or ${request.maximumCaptureRows} capture rows).`,
      flavour: "ecmascript",
      provenance: "derived",
    });
  }
  if (previews.truncated) {
    diagnostics.push({
      id: "ecmascript-value-previews-truncated",
      source: "execution-engine",
      severity: "warning",
      code: "result-value-preview-limit",
      message: `Displayed result values were clipped to ${CAPTURE_VALUE_PREVIEW_UTF16.toLocaleString()} UTF-16 units each and ${TOTAL_VALUE_PREVIEW_UTF16.toLocaleString()} units overall. Exact engine ranges are retained.`,
      flavour: "ecmascript",
      provenance: "derived",
    });
  }
  return {
    accepted: true,
    engine: engineInfo(),
    flags,
    matches,
    diagnostics,
    elapsedMs: performance.now() - start,
    truncated,
  };
}

export function executeEcmaScript(
  request: RegexExecutionRequest,
): RegexExecutionResult {
  return executeEcmaScriptInternal(request);
}

function utf8Prefix(
  value: string,
  maximumBytes: number,
): {
  readonly value: string;
  readonly bytes: number;
  readonly complete: boolean;
} {
  let bytes = 0;
  let index = 0;
  while (index < value.length) {
    const first = value.charCodeAt(index);
    let width: number;
    let units = 1;
    if (first <= 0x7f) {
      width = 1;
    } else if (first <= 0x7ff) {
      width = 2;
    } else if (
      first >= 0xd800 &&
      first <= 0xdbff &&
      index + 1 < value.length &&
      value.charCodeAt(index + 1) >= 0xdc00 &&
      value.charCodeAt(index + 1) <= 0xdfff
    ) {
      width = 4;
      units = 2;
    } else {
      width = 3;
    }
    if (bytes + width > maximumBytes) break;
    bytes += width;
    index += units;
  }
  return {
    value: value.slice(0, index),
    bytes,
    complete: index === value.length,
  };
}

class BoundedUtf8Output {
  readonly #maximumBytes: number;
  readonly #chunks: string[] = [];
  #bytes = 0;
  #truncated = false;

  constructor(maximumBytes: number) {
    this.#maximumBytes = Math.max(0, Math.floor(maximumBytes));
  }

  append(value: string): boolean {
    if (this.#truncated || value.length === 0) return !this.#truncated;
    const prefix = utf8Prefix(value, this.#maximumBytes - this.#bytes);
    if (prefix.value.length > 0) this.#chunks.push(prefix.value);
    this.#bytes += prefix.bytes;
    this.#truncated = !prefix.complete;
    return !this.#truncated;
  }

  finish(): {
    readonly value: string;
    readonly bytes: number;
    readonly truncated: boolean;
  } {
    return {
      value: this.#chunks.join(""),
      bytes: this.#bytes,
      truncated: this.#truncated,
    };
  }
}

function* substitutionChunks(
  replacement: string,
  match: RegExpExecArray,
  subject: string,
): Generator<string> {
  const captureCount = match.length - 1;
  const matchEnd = match.index + match[0].length;
  let cursor = 0;
  while (cursor < replacement.length) {
    const dollar = replacement.indexOf("$", cursor);
    if (dollar < 0) {
      yield replacement.slice(cursor);
      return;
    }
    if (dollar > cursor) yield replacement.slice(cursor, dollar);
    const next = replacement[dollar + 1];
    if (next === undefined) {
      yield "$";
      return;
    }
    if (next === "$") {
      yield "$";
      cursor = dollar + 2;
      continue;
    }
    if (next === "&") {
      yield match[0];
      cursor = dollar + 2;
      continue;
    }
    if (next === "`") {
      yield subject.slice(0, match.index);
      cursor = dollar + 2;
      continue;
    }
    if (next === "'") {
      yield subject.slice(matchEnd);
      cursor = dollar + 2;
      continue;
    }
    if (next === "<" && match.groups !== undefined) {
      const close = replacement.indexOf(">", dollar + 2);
      if (close >= 0) {
        const name = replacement.slice(dollar + 2, close);
        yield match.groups[name] ?? "";
        cursor = close + 1;
        continue;
      }
    }
    if (/[0-9]/u.test(next)) {
      const first = Number.parseInt(next, 10);
      const following = replacement[dollar + 2];
      const hasSecondDigit =
        following !== undefined && /[0-9]/u.test(following);
      const second = hasSecondDigit
        ? Number.parseInt(`${next}${following}`, 10)
        : 0;
      if (hasSecondDigit && second > 0 && second <= captureCount) {
        yield match[second] ?? "";
        cursor = dollar + 3;
        continue;
      }
      if (first > 0 && first <= captureCount) {
        yield match[first] ?? "";
        cursor = dollar + 2;
        continue;
      }
    }
    yield "$";
    cursor = dollar + 1;
  }
}

function appendOutputLimitDiagnostic(
  execution: RegexExecutionResult,
  truncated: boolean,
  maximumOutputBytes: number,
): RegexExecutionResult {
  if (!truncated) return execution;
  return {
    ...execution,
    diagnostics: [
      ...execution.diagnostics,
      {
        id: "ecmascript-output-truncated",
        source: "execution-engine",
        severity: "warning",
        code: "replacement-output-limit",
        message: `Replacement preview was truncated at ${maximumOutputBytes} UTF-8 bytes.`,
        flavour: "ecmascript",
        provenance: "derived",
      },
    ],
  };
}

function boundedReplacement(request: RegexReplacementRequest): {
  readonly execution: RegexExecutionResult;
  readonly value: string;
  readonly bytes: number;
  readonly truncated: boolean;
} {
  const output = new BoundedUtf8Output(request.maximumOutputBytes);
  let nextSourcePosition = 0;
  let outputStopped = false;
  const execution = executeEcmaScriptInternal(request, (match) => {
    if (outputStopped) return;
    const matchEnd = match.index + match[0].length;
    if (
      !output.append(request.subject.slice(nextSourcePosition, match.index))
    ) {
      outputStopped = true;
      return;
    }
    for (const chunk of substitutionChunks(
      request.replacement,
      match,
      request.subject,
    )) {
      if (!output.append(chunk)) {
        outputStopped = true;
        return;
      }
    }
    nextSourcePosition = matchEnd;
  });
  if (!outputStopped) {
    output.append(request.subject.slice(nextSourcePosition));
  }
  return { execution, ...output.finish() };
}

export function replaceEcmaScript(
  request: RegexReplacementRequest,
): RegexReplacementResult {
  validateRequest(request, request);
  const output = boundedReplacement(request);
  const execution = appendOutputLimitDiagnostic(
    output.execution,
    output.truncated,
    request.maximumOutputBytes,
  );
  return {
    execution,
    output: output.value,
    outputBytes: output.bytes,
    outputTruncated: output.truncated,
    truncated: output.truncated || execution.truncated,
  };
}

export class EcmaScriptEngineAdapter implements RegexEngineAdapter {
  readonly flavour = "ecmascript" as const;

  async load(): Promise<RegexEngineInfo> {
    return engineInfo();
  }

  async execute(request: RegexExecutionRequest): Promise<RegexExecutionResult> {
    return executeEcmaScript(request);
  }

  async replace(
    request: RegexReplacementRequest,
  ): Promise<RegexReplacementResult> {
    return replaceEcmaScript(request);
  }

  terminate(): void {
    // Lifecycle termination is performed by killing the containing worker.
  }
}
