import { utf8ByteLength } from "../execution/request-limits";
import { ANALYSIS_LIMITS } from "./analysis-limits";
import type {
  AnalysisEngineIdentity,
  BenchmarkSubject,
  BenchmarkWorkerSample,
  GrowthProbeSubject,
  GrowthWorkerSample,
} from "./analysis.types";

const FLAG_ORDER = "dgimsuvy";

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

export function ecmaScriptAnalysisIdentity(): AnalysisEngineIdentity {
  const identity = runtimeIdentity();
  return {
    flavour: "ecmascript",
    engineName: "Native ECMAScript RegExp",
    engineVersion: identity,
    runtimeVersion: identity,
    nativeOffsetUnit: "utf16",
  };
}

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

function effectiveFlags(flags: readonly string[], scanAll: boolean): string {
  for (const flag of flags) {
    if (!FLAG_ORDER.includes(flag)) {
      throw new RangeError(
        `Unsupported ECMAScript analysis flag ${JSON.stringify(flag)}.`,
      );
    }
  }
  const unique = new Set(flags);
  if (hasIndicesSupport()) unique.add("d");
  if (scanAll && !unique.has("g") && !unique.has("y")) unique.add("g");
  return FLAG_ORDER.split("")
    .filter((flag) => unique.has(flag))
    .join("");
}

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 collectMatches(
  expression: RegExp,
  subject: string,
  iterate: boolean,
  maximumMatches: number,
): {
  readonly count: number;
  readonly matched: boolean;
  readonly truncated: boolean;
} {
  const unicode = expression.unicode || expression.flags.includes("v");
  expression.lastIndex = 0;
  let count = 0;
  while (true) {
    const match = expression.exec(subject);
    if (!match) break;
    count += 1;
    if (count >= maximumMatches) {
      return { count, matched: true, truncated: iterate };
    }
    if (!iterate) break;
    if (match[0].length === 0) {
      expression.lastIndex = advanceStringIndex(
        subject,
        expression.lastIndex,
        unicode,
      );
      if (expression.lastIndex > subject.length) break;
    }
  }
  return { count, matched: count > 0, truncated: false };
}

function assertWorkerInput(
  request: BenchmarkSubject | GrowthProbeSubject,
): void {
  if (request.flavour !== "ecmascript") {
    throw new RangeError("The analysis worker supports ECMAScript only.");
  }
  if (request.pattern.length > 64 * 1024) {
    throw new RangeError("Pattern exceeds the 64 Ki UTF-16 analysis limit.");
  }
  if (utf8ByteLength(request.subject) > 16 * 1024 * 1024) {
    throw new RangeError("Subject exceeds the 16 MiB analysis limit.");
  }
  if (
    !Number.isSafeInteger(request.maximumMatches) ||
    request.maximumMatches < 1 ||
    request.maximumMatches > 10_000
  ) {
    throw new RangeError("Analysis match limit must be between 1 and 10,000.");
  }
}

function replacementWorstCaseUtf16(
  replacement: string,
  subjectUtf16: number,
  matchCount: number,
): number {
  let references = 0;
  for (let index = 0; index < replacement.length - 1; index += 1) {
    if (
      replacement[index] === "$" &&
      /[`'&0-9<]/u.test(replacement[index + 1] ?? "")
    ) {
      references += 1;
      index += 1;
    }
  }
  const literal = replacement.length;
  return (
    subjectUtf16 +
    matchCount * (literal + references * Math.max(1, subjectUtf16))
  );
}

function compileFailure(
  effective: string,
  subject: string,
  compileMs: number,
  error: unknown,
): BenchmarkWorkerSample {
  return {
    accepted: false,
    compileError:
      error instanceof Error
        ? error.message
        : "The native ECMAScript engine rejected the pattern.",
    effectiveFlags: effective,
    subjectBytes: utf8ByteLength(subject),
    subjectUtf16: subject.length,
    compileMs,
    matchCount: 0,
    matched: false,
    matchCollectionTruncated: false,
  };
}

export function benchmarkEcmaScriptSample(
  request: BenchmarkSubject,
): BenchmarkWorkerSample {
  assertWorkerInput(request);
  if (request.replacement.length > 64 * 1024) {
    throw new RangeError(
      "Replacement exceeds the 64 Ki UTF-16 analysis limit.",
    );
  }
  const flags = effectiveFlags(request.flags, request.scanAll);
  const compileStart = performance.now();
  let compiled: RegExp;
  try {
    compiled = new RegExp(request.pattern, flags);
  } catch (error) {
    return compileFailure(
      flags,
      request.subject,
      performance.now() - compileStart,
      error,
    );
  }
  const compileMs = performance.now() - compileStart;
  const iterate = flags.includes("g") || request.scanAll;

  const firstStart = performance.now();
  compiled.exec(request.subject);
  const firstMatchMs = performance.now() - firstStart;

  const allExpression = new RegExp(request.pattern, flags);
  const allStart = performance.now();
  const collection = collectMatches(
    allExpression,
    request.subject,
    iterate,
    request.maximumMatches,
  );
  const allMatchesMs = performance.now() - allStart;
  const subjectBytes = utf8ByteLength(request.subject);
  const throughputBytesPerSecond =
    allMatchesMs > 0 ? (subjectBytes / allMatchesMs) * 1_000 : undefined;

  const worstCaseOutput = replacementWorstCaseUtf16(
    request.replacement,
    request.subject.length,
    collection.count,
  );
  let replacementMs: number | undefined;
  let replacementOutputUtf16: number | undefined;
  let replacementSkippedReason: string | undefined;
  if (flags.includes("y") && request.scanAll) {
    replacementSkippedReason =
      "Explicit multi-match sticky replacement is not timed with native String.replace because that API replaces only one sticky match.";
  } else if (collection.truncated) {
    replacementSkippedReason =
      "Match collection reached its bound, so total replacement work and output are not known.";
  } else if (
    worstCaseOutput > ANALYSIS_LIMITS.maximumReplacementBenchmarkOutputUtf16
  ) {
    replacementSkippedReason = `Conservative output estimate exceeds ${ANALYSIS_LIMITS.maximumReplacementBenchmarkOutputUtf16.toLocaleString()} UTF-16 units.`;
  } else {
    const replacementExpression = new RegExp(request.pattern, flags);
    const replacementStart = performance.now();
    const output = request.subject.replace(
      replacementExpression,
      request.replacement,
    );
    replacementMs = performance.now() - replacementStart;
    replacementOutputUtf16 = output.length;
  }

  return {
    accepted: true,
    effectiveFlags: flags,
    subjectBytes,
    subjectUtf16: request.subject.length,
    compileMs,
    firstMatchMs,
    allMatchesMs,
    ...(replacementMs === undefined ? {} : { replacementMs }),
    ...(replacementSkippedReason ? { replacementSkippedReason } : {}),
    ...(throughputBytesPerSecond === undefined
      ? {}
      : { throughputBytesPerSecond }),
    matchCount: collection.count,
    matched: collection.matched,
    matchCollectionTruncated: collection.truncated,
    ...(replacementOutputUtf16 === undefined ? {} : { replacementOutputUtf16 }),
  };
}

export function probeEcmaScriptGrowth(
  request: GrowthProbeSubject,
): GrowthWorkerSample {
  assertWorkerInput(request);
  const flags = effectiveFlags(request.flags, request.scanAll);
  let expression: RegExp;
  try {
    expression = new RegExp(request.pattern, flags);
  } catch (error) {
    return {
      accepted: false,
      compileError:
        error instanceof Error
          ? error.message
          : "The native ECMAScript engine rejected the pattern.",
      effectiveFlags: flags,
      subjectBytes: utf8ByteLength(request.subject),
      subjectUtf16: request.subject.length,
      matchCount: 0,
      matched: false,
      matchCollectionTruncated: false,
    };
  }
  const iterate = flags.includes("g") || request.scanAll;
  const started = performance.now();
  const collection = collectMatches(
    expression,
    request.subject,
    iterate,
    request.maximumMatches,
  );
  const executionMs = performance.now() - started;
  return {
    accepted: true,
    effectiveFlags: flags,
    subjectBytes: utf8ByteLength(request.subject),
    subjectUtf16: request.subject.length,
    executionMs,
    matchCount: collection.count,
    matched: collection.matched,
    matchCollectionTruncated: collection.truncated,
  };
}
