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

export const PORTABLE_BRIDGE_ABI_VERSION = 1;

const CAPTURE_VALUE_PREVIEW_UTF16 = 64 * 1024;
const TOTAL_VALUE_PREVIEW_UTF16 = 8 * 1024 * 1024;

export interface PortableBridgeRequest {
  readonly abi: typeof PORTABLE_BRIDGE_ABI_VERSION;
  readonly operation: "execute" | "replace";
  readonly pattern: string;
  readonly flags: string;
  readonly options: RegexEngineOptions;
  readonly subject: string;
  readonly scanAll: boolean;
  readonly maximumMatches: number;
  readonly maximumCaptureRows: number;
  readonly replacement?: string;
  readonly maximumOutputBytes?: number;
}

export interface PortableRegexBridge {
  load(): Promise<Readonly<Record<string, unknown>>>;
  run(request: PortableBridgeRequest): Promise<unknown>;
  terminate?(): void;
}

export interface PortableEngineConfiguration {
  readonly flavour: RegexFlavourId;
  readonly flavourLabel: string;
  readonly expectedFlavourVersion: string;
  readonly engineName: string;
  readonly engineVersion: string;
  readonly runtimeVersion: string;
  readonly offsetUnit: NativeTextRange["unit"];
  readonly supportedFlags: string;
  readonly capabilities: RegexEngineCapabilities;
  /**
   * Reject UTF-16 values that cannot make a lossless UTF-8 round trip before
   * handing them to a byte-oriented WebAssembly runtime.
   */
  readonly rejectLoneSurrogates?: boolean;
  readonly validateOptions?: (options: RegexEngineOptions) => void;
  readonly validateIdentity: (
    identity: Readonly<Record<string, unknown>>,
  ) => void;
}

interface PortableFailure {
  readonly abi: number;
  readonly ok: false;
  readonly phase: "bridge" | "compile" | "match" | "replacement";
  readonly code: string;
  readonly message: string;
  readonly position?: number;
}

type NativeSpan = readonly [number, number];

interface PortableMatch {
  readonly span: NativeSpan;
  readonly captures: readonly (NativeSpan | null)[];
}

interface PortableSuccess {
  readonly abi: number;
  readonly ok: true;
  readonly groupCount: number;
  readonly groupNames: readonly (readonly [number, string])[];
  readonly matches: readonly PortableMatch[];
  readonly resultsTruncated: boolean;
  readonly output?: string;
  readonly outputBytes?: number;
  readonly outputTruncated?: boolean;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function nativeTextLength(
  value: string,
  unit: NativeTextRange["unit"],
): number {
  switch (unit) {
    case "utf16":
      return value.length;
    case "code-point":
      return [...value].length;
    case "utf8-byte":
    case "byte":
      return utf8ByteLength(value);
  }
}

function selectedNativeOffsetsToUtf16(
  value: string,
  unit: NativeTextRange["unit"],
  selected: ReadonlySet<number>,
): ReadonlyMap<number, number> {
  const result = new Map<number, number>();
  if (selected.has(0)) result.set(0, 0);
  let nativeOffset = 0;
  let utf16Offset = 0;
  for (const character of value) {
    switch (unit) {
      case "utf16":
        nativeOffset += character.length;
        break;
      case "code-point":
        nativeOffset += 1;
        break;
      case "utf8-byte":
      case "byte":
        nativeOffset += utf8ByteLength(character);
        break;
    }
    utf16Offset += character.length;
    if (selected.has(nativeOffset)) result.set(nativeOffset, utf16Offset);
  }
  if (result.size !== selected.size) {
    throw new Error(
      "The engine returned an offset outside the input or between encoded characters.",
    );
  }
  return result;
}

function isNativeSpan(value: unknown, maximum: number): value is NativeSpan {
  return (
    Array.isArray(value) &&
    value.length === 2 &&
    Number.isSafeInteger(value[0]) &&
    Number.isSafeInteger(value[1]) &&
    Number(value[0]) >= 0 &&
    Number(value[0]) <= Number(value[1]) &&
    Number(value[1]) <= maximum
  );
}

function requireFailure(value: unknown): PortableFailure | undefined {
  if (!isRecord(value) || value.ok !== false) return undefined;
  if (
    value.abi !== PORTABLE_BRIDGE_ABI_VERSION ||
    !["bridge", "compile", "match", "replacement"].includes(
      String(value.phase),
    ) ||
    typeof value.code !== "string" ||
    typeof value.message !== "string" ||
    (value.position !== undefined &&
      (!Number.isSafeInteger(value.position) || Number(value.position) < 0))
  ) {
    throw new Error("The engine bridge returned an invalid failure record.");
  }
  return value as unknown as PortableFailure;
}

function requireSuccess(
  value: unknown,
  request: RegexExecutionRequest,
  unit: NativeTextRange["unit"],
  replacementRequest?: RegexReplacementRequest,
): PortableSuccess {
  const maximumNativeOffset = nativeTextLength(request.subject, unit);
  if (
    !isRecord(value) ||
    value.abi !== PORTABLE_BRIDGE_ABI_VERSION ||
    value.ok !== true ||
    !Number.isSafeInteger(value.groupCount) ||
    Number(value.groupCount) < 0 ||
    Number(value.groupCount) > DEFAULT_REGEX_LIMITS.maximumCaptureGroups ||
    !Array.isArray(value.groupNames) ||
    !Array.isArray(value.matches) ||
    value.matches.length > request.maximumMatches ||
    typeof value.resultsTruncated !== "boolean"
  ) {
    throw new Error("The engine bridge returned an invalid result record.");
  }
  const groupCount = Number(value.groupCount);
  const namedGroups = new Set<number>();
  for (const entry of value.groupNames) {
    if (
      !Array.isArray(entry) ||
      entry.length !== 2 ||
      !Number.isSafeInteger(entry[0]) ||
      Number(entry[0]) < 1 ||
      Number(entry[0]) > groupCount ||
      namedGroups.has(Number(entry[0])) ||
      typeof entry[1] !== "string" ||
      entry[1].length > 1_024
    ) {
      throw new Error("The engine bridge returned invalid capture names.");
    }
    namedGroups.add(Number(entry[0]));
  }
  let captureRows = 0;
  for (const entry of value.matches) {
    if (
      !isRecord(entry) ||
      !isNativeSpan(entry.span, maximumNativeOffset) ||
      !Array.isArray(entry.captures) ||
      entry.captures.length !== groupCount
    ) {
      throw new Error("The engine bridge returned an invalid match record.");
    }
    captureRows += Math.max(1, groupCount);
    if (captureRows > request.maximumCaptureRows) {
      throw new Error("The engine bridge exceeded the capture-row limit.");
    }
    for (const capture of entry.captures) {
      if (capture !== null && !isNativeSpan(capture, maximumNativeOffset)) {
        throw new Error("The engine bridge returned an invalid capture range.");
      }
    }
  }
  if (replacementRequest) {
    if (
      typeof value.output !== "string" ||
      !Number.isSafeInteger(value.outputBytes) ||
      Number(value.outputBytes) < 0 ||
      Number(value.outputBytes) > replacementRequest.maximumOutputBytes ||
      utf8ByteLength(value.output) !== value.outputBytes ||
      typeof value.outputTruncated !== "boolean"
    ) {
      throw new Error(
        "The engine bridge returned invalid bounded replacement output.",
      );
    }
  } else if (
    value.output !== undefined ||
    value.outputBytes !== undefined ||
    value.outputTruncated !== undefined
  ) {
    throw new Error(
      "The engine bridge returned replacement data for a match request.",
    );
  }
  return value as unknown as PortableSuccess;
}

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 materializeMatches(
  native: PortableSuccess,
  request: RegexExecutionRequest,
  unit: NativeTextRange["unit"],
): {
  readonly matches: readonly RegexMatchResult[];
  readonly valuesTruncated: boolean;
} {
  const names = new Map(native.groupNames);
  const offsets = new Set<number>();
  for (const match of native.matches) {
    offsets.add(match.span[0]);
    offsets.add(match.span[1]);
    for (const capture of match.captures) {
      if (capture) {
        offsets.add(capture[0]);
        offsets.add(capture[1]);
      }
    }
  }
  const editorOffsets = selectedNativeOffsetsToUtf16(
    request.subject,
    unit,
    offsets,
  );
  const editorOffset = (nativeOffset: number): number => {
    const result = editorOffsets.get(nativeOffset);
    if (result === undefined) {
      throw new Error("An engine offset was not normalized.");
    }
    return result;
  };
  const previews = new ValuePreviewBudget();
  const matches = native.matches.map((match, index): RegexMatchResult => {
    const startUtf16 = editorOffset(match.span[0]);
    const endUtf16 = editorOffset(match.span[1]);
    const rawValue = request.subject.slice(startUtf16, endUtf16);
    const value = previews.take(rawValue);
    const captures = match.captures.map((span, captureIndex): CaptureResult => {
      const groupNumber = captureIndex + 1;
      const groupName = names.get(groupNumber);
      if (!span) {
        return {
          groupNumber,
          ...(groupName ? { groupName } : {}),
          status: "did-not-participate",
        };
      }
      const captureStartUtf16 = editorOffset(span[0]);
      const captureEndUtf16 = editorOffset(span[1]);
      const rawCapture = request.subject.slice(
        captureStartUtf16,
        captureEndUtf16,
      );
      const capture = previews.take(rawCapture);
      return {
        groupNumber,
        ...(groupName ? { groupName } : {}),
        value: capture.value,
        status: capture.truncated
          ? "truncated"
          : rawCapture.length === 0
            ? "matched-empty"
            : "participated",
        range: {
          startUtf16: captureStartUtf16,
          endUtf16: captureEndUtf16,
        },
        nativeRange: { start: span[0], end: span[1], unit },
      };
    });
    return {
      matchNumber: index + 1,
      value: value.value,
      valueStatus: value.truncated ? "truncated" : "complete",
      range: { startUtf16, endUtf16 },
      nativeRange: {
        start: match.span[0],
        end: match.span[1],
        unit,
      },
      captures,
    };
  });
  return { matches, valuesTruncated: previews.truncated };
}

function validateRequest(
  request: RegexExecutionRequest,
  configuration: PortableEngineConfiguration,
  replacement?: RegexReplacementRequest,
): void {
  if (request.flavour !== configuration.flavour) {
    throw new Error(
      `${configuration.flavourLabel} adapter cannot execute ${request.flavour}.`,
    );
  }
  if (
    request.flavourVersion !== undefined &&
    request.flavourVersion !== configuration.expectedFlavourVersion
  ) {
    throw new RangeError(
      `${configuration.flavourLabel} version ${JSON.stringify(request.flavourVersion)} is unsupported by this engine.`,
    );
  }
  if (configuration.rejectLoneSurrogates) {
    const values = [
      ["pattern", request.pattern],
      ["subject", request.subject],
      ...(replacement
        ? ([["replacement", replacement.replacement]] as const)
        : []),
    ] as const;
    for (const [label, value] of values) {
      for (let index = 0; index < value.length; index += 1) {
        const first = value.charCodeAt(index);
        if (first >= 0xd800 && first <= 0xdbff) {
          const second = value.charCodeAt(index + 1);
          if (second >= 0xdc00 && second <= 0xdfff) {
            index += 1;
            continue;
          }
          throw new RangeError(
            `${configuration.flavourLabel} ${label} contains an unpaired UTF-16 surrogate that its UTF-8 runtime cannot represent losslessly.`,
          );
        }
        if (first >= 0xdc00 && first <= 0xdfff) {
          throw new RangeError(
            `${configuration.flavourLabel} ${label} contains an unpaired UTF-16 surrogate that its UTF-8 runtime cannot represent losslessly.`,
          );
        }
      }
    }
  }
  if (
    new Set(request.flags).size !== request.flags.length ||
    request.flags.some(
      (flag) =>
        flag.length !== 1 || !configuration.supportedFlags.includes(flag),
    )
  ) {
    throw new RangeError(
      `The request contains unsupported ${configuration.flavourLabel} flags.`,
    );
  }
  const options = request.options ?? {};
  if (configuration.validateOptions) {
    configuration.validateOptions(options);
  } else if (Object.keys(options).length > 0) {
    throw new RangeError(
      `${configuration.flavourLabel} does not accept engine options.`,
    );
  }
  if (request.pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
    throw new RangeError(
      `${configuration.flavourLabel} patterns are limited to ${DEFAULT_REGEX_LIMITS.patternHardLengthUtf16.toLocaleString()} UTF-16 units.`,
    );
  }
  if (
    utf8ByteLength(request.subject) >
    DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
  ) {
    throw new RangeError(
      `${configuration.flavourLabel} 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("Result limits are outside the bounded contract.");
  }
  if (
    replacement &&
    (replacement.replacement.length >
      DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16 ||
      !Number.isSafeInteger(replacement.maximumOutputBytes) ||
      replacement.maximumOutputBytes < 1 ||
      replacement.maximumOutputBytes >
        DEFAULT_REGEX_LIMITS.maximumReplacementOutputBytes)
  ) {
    throw new RangeError(
      "Replacement or output limits are outside the bounded contract.",
    );
  }
}

function normalizedFlags(
  userFlags: readonly string[],
  supportedFlags: string,
  scanAll: boolean,
): RegexExecutionFlags {
  const selected = new Set(userFlags);
  const internallyAddedGlobalFlag = scanAll && !selected.has("g");
  if (internallyAddedGlobalFlag) selected.add("g");
  return {
    userFlags: [...supportedFlags]
      .filter((flag) => userFlags.includes(flag))
      .join(""),
    effectiveFlags: [...supportedFlags]
      .filter((flag) => selected.has(flag))
      .join(""),
    internallyAddedIndicesFlag: false,
    internallyAddedGlobalFlag,
  };
}

function diagnosticForFailure(
  failure: PortableFailure,
  request: RegexExecutionRequest,
  configuration: PortableEngineConfiguration,
): RegexDiagnostic {
  let range:
    { readonly startUtf16: number; readonly endUtf16: number } | undefined;
  if (
    failure.phase === "compile" &&
    failure.position !== undefined &&
    failure.position <=
      nativeTextLength(request.pattern, configuration.offsetUnit)
  ) {
    const converted = selectedNativeOffsetsToUtf16(
      request.pattern,
      configuration.offsetUnit,
      new Set([failure.position]),
    ).get(failure.position);
    if (converted !== undefined) {
      range = { startUtf16: converted, endUtf16: converted };
    }
  }
  return {
    id: `${configuration.flavour}-${failure.phase}-${failure.code}`,
    source: "execution-engine",
    severity: "error",
    code: failure.code,
    message: `${configuration.flavourLabel} ${failure.phase} error: ${failure.message.slice(0, 1_024)}`,
    ...(range ? { range } : {}),
    flavour: configuration.flavour,
    provenance: "reported",
  };
}

function diagnosticsForSuccess(
  native: PortableSuccess,
  request: RegexExecutionRequest,
  configuration: PortableEngineConfiguration,
  valuesTruncated: boolean,
  replacement?: RegexReplacementRequest,
): readonly RegexDiagnostic[] {
  const diagnostics: RegexDiagnostic[] = [];
  if (native.resultsTruncated) {
    diagnostics.push({
      id: `${configuration.flavour}-results-truncated`,
      source: "execution-engine",
      severity: "warning",
      code: "result-limit",
      message: `Results reached the configured limit (${request.maximumMatches.toLocaleString()} matches or ${request.maximumCaptureRows.toLocaleString()} capture rows).`,
      flavour: configuration.flavour,
      provenance: "derived",
    });
  }
  if (valuesTruncated) {
    diagnostics.push({
      id: `${configuration.flavour}-value-previews-truncated`,
      source: "execution-engine",
      severity: "warning",
      code: "result-value-preview-limit",
      message: `Displayed ${configuration.flavourLabel} values were clipped to bounded previews; exact ranges are retained.`,
      flavour: configuration.flavour,
      provenance: "derived",
    });
  }
  if (replacement && native.outputTruncated) {
    diagnostics.push({
      id: `${configuration.flavour}-output-truncated`,
      source: "execution-engine",
      severity: "warning",
      code: "replacement-output-limit",
      message: `Replacement preview was truncated at ${replacement.maximumOutputBytes.toLocaleString()} UTF-8 bytes.`,
      flavour: configuration.flavour,
      provenance: "derived",
    });
  }
  return diagnostics;
}

function failedExecution(
  info: RegexEngineInfo,
  flags: RegexExecutionFlags,
  start: number,
  diagnostic: RegexDiagnostic,
): RegexExecutionResult {
  return {
    accepted: false,
    engine: info,
    flags,
    matches: [],
    diagnostics: [diagnostic],
    elapsedMs: performance.now() - start,
    truncated: false,
  };
}

export class PortableEngineAdapter implements RegexEngineAdapter {
  readonly flavour: RegexFlavourId;
  readonly #bridge: PortableRegexBridge;
  readonly #configuration: PortableEngineConfiguration;
  readonly #info: RegexEngineInfo;
  #loadPromise: Promise<RegexEngineInfo> | undefined;
  #terminated = false;

  constructor(
    bridge: PortableRegexBridge,
    configuration: PortableEngineConfiguration,
  ) {
    this.flavour = configuration.flavour;
    this.#bridge = bridge;
    this.#configuration = configuration;
    this.#info = {
      flavour: configuration.flavour,
      adapterVersion: APPLICATION_VERSION,
      engineName: configuration.engineName,
      engineVersion: configuration.engineVersion,
      runtimeVersion: configuration.runtimeVersion,
      offsetUnit: configuration.offsetUnit,
      capabilities: configuration.capabilities,
    };
  }

  load(): Promise<RegexEngineInfo> {
    this.#loadPromise ??= Promise.resolve().then(async () => {
      if (this.#terminated) {
        throw new Error("The engine adapter has been terminated.");
      }
      const identity = await this.#bridge.load();
      this.#configuration.validateIdentity(identity);
      return this.#info;
    });
    return this.#loadPromise;
  }

  async execute(request: RegexExecutionRequest): Promise<RegexExecutionResult> {
    await this.load();
    validateRequest(request, this.#configuration);
    const start = performance.now();
    const flags = normalizedFlags(
      request.flags,
      this.#configuration.supportedFlags,
      request.scanAll,
    );
    const raw = await this.#bridge.run({
      abi: PORTABLE_BRIDGE_ABI_VERSION,
      operation: "execute",
      pattern: request.pattern,
      flags: flags.effectiveFlags,
      options: request.options ?? {},
      subject: request.subject,
      scanAll: request.scanAll,
      maximumMatches: request.maximumMatches,
      maximumCaptureRows: request.maximumCaptureRows,
    });
    const failure = requireFailure(raw);
    if (failure) {
      return failedExecution(
        this.#info,
        flags,
        start,
        diagnosticForFailure(failure, request, this.#configuration),
      );
    }
    const native = requireSuccess(raw, request, this.#configuration.offsetUnit);
    const materialized = materializeMatches(
      native,
      request,
      this.#configuration.offsetUnit,
    );
    return {
      accepted: true,
      engine: this.#info,
      flags,
      matches: materialized.matches,
      diagnostics: diagnosticsForSuccess(
        native,
        request,
        this.#configuration,
        materialized.valuesTruncated,
      ),
      elapsedMs: performance.now() - start,
      truncated: native.resultsTruncated,
    };
  }

  async replace(
    request: RegexReplacementRequest,
  ): Promise<RegexReplacementResult> {
    await this.load();
    validateRequest(request, this.#configuration, request);
    const start = performance.now();
    const flags = normalizedFlags(
      request.flags,
      this.#configuration.supportedFlags,
      request.scanAll,
    );
    const raw = await this.#bridge.run({
      abi: PORTABLE_BRIDGE_ABI_VERSION,
      operation: "replace",
      pattern: request.pattern,
      flags: flags.effectiveFlags,
      options: request.options ?? {},
      subject: request.subject,
      scanAll: request.scanAll,
      maximumMatches: request.maximumMatches,
      maximumCaptureRows: request.maximumCaptureRows,
      replacement: request.replacement,
      maximumOutputBytes: request.maximumOutputBytes,
    });
    const failure = requireFailure(raw);
    if (failure) {
      const execution = failedExecution(
        this.#info,
        flags,
        start,
        diagnosticForFailure(failure, request, this.#configuration),
      );
      return {
        execution,
        output: "",
        outputBytes: 0,
        outputTruncated: false,
        truncated: false,
      };
    }
    const native = requireSuccess(
      raw,
      request,
      this.#configuration.offsetUnit,
      request,
    );
    const materialized = materializeMatches(
      native,
      request,
      this.#configuration.offsetUnit,
    );
    const execution: RegexExecutionResult = {
      accepted: true,
      engine: this.#info,
      flags,
      matches: materialized.matches,
      diagnostics: diagnosticsForSuccess(
        native,
        request,
        this.#configuration,
        materialized.valuesTruncated,
        request,
      ),
      elapsedMs: performance.now() - start,
      truncated: native.resultsTruncated,
    };
    return {
      execution,
      output: native.output ?? "",
      outputBytes: native.outputBytes ?? 0,
      outputTruncated: native.outputTruncated ?? false,
      truncated: native.resultsTruncated || (native.outputTruncated ?? false),
    };
  }

  terminate(): void {
    if (this.#terminated) return;
    this.#terminated = true;
    this.#bridge.terminate?.();
  }
}
