import RUBY_BRIDGE_SOURCE from "../../../../../engines/ruby/bridge.rb?raw";
import { APPLICATION_VERSION } from "../../../../version";
import type { RegexDiagnostic } from "../../../model/diagnostics";
import type { RegexEngineInfo } from "../../../model/flavour";
import type {
  CaptureResult,
  RegexExecutionFlags,
  RegexExecutionRequest,
  RegexExecutionResult,
  RegexMatchResult,
  RegexReplacementRequest,
  RegexReplacementResult,
} from "../../../model/match";
import type { RegexEngineAdapter } from "../../RegexEngineAdapter";
import {
  buildCodePointOffsetMap,
  codePointToUtf16,
} from "../../offsets/codepoint-to-utf16";
import { DEFAULT_REGEX_LIMITS, utf8ByteLength } from "../../request-limits";
import type { RubyValue, RubyVm } from "./ruby-module";

export const RUBY_BRIDGE_ABI_VERSION = 2;
export const RUBY_ENGINE_VERSION = "CRuby 4.0.0 Regexp";
export const RUBY_FLAVOUR_VERSION = "CRuby 4.0.0 WebAssembly";
export const RUBY_WASM_PACKAGE_VERSION = "2.9.3-2.9.4";

const FLAG_ORDER = "gimx";
const RUBY_REQUEST_MAGIC = new TextEncoder().encode("RGXRUBY1");
const RUBY_REQUEST_FIELD_COUNT = 8;
const MAXIMUM_SERIALIZED_RESPONSE_UTF16 = 16 * 1024 * 1024;
const CAPTURE_VALUE_PREVIEW_UTF16 = 64 * 1024;
const TOTAL_VALUE_PREVIEW_UTF16 = 8 * 1024 * 1024;

type RubyRequestWriter = (payload: Uint8Array) => void;
type RubyOutputReader = () => Uint8Array;

function encodeRubyRequest(fields: readonly string[]): Uint8Array {
  if (fields.length !== RUBY_REQUEST_FIELD_COUNT) {
    throw new Error("The Ruby bridge request has the wrong field count.");
  }
  const encoder = new TextEncoder();
  const encoded = fields.map((field) => encoder.encode(field));
  const byteLength =
    RUBY_REQUEST_MAGIC.byteLength +
    encoded.reduce((total, field) => total + 4 + field.byteLength, 0);
  const payload = new Uint8Array(byteLength);
  payload.set(RUBY_REQUEST_MAGIC);
  const view = new DataView(payload.buffer);
  let offset = RUBY_REQUEST_MAGIC.byteLength;
  for (const field of encoded) {
    view.setUint32(offset, field.byteLength, false);
    offset += 4;
    payload.set(field, offset);
    offset += field.byteLength;
  }
  return payload;
}

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

interface NativeIdentity {
  readonly abi: number;
  readonly ok: true;
  readonly identity: {
    readonly implementation: string;
    readonly rubyVersion: string;
    readonly platform: string;
    readonly defaultExternalEncoding: string;
  };
}

type NativeSpan = readonly [number, number];

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

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

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 isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function parseNativeError(value: unknown): NativeError | undefined {
  if (!isRecord(value) || value.ok !== false) return undefined;
  if (
    value.abi !== RUBY_BRIDGE_ABI_VERSION ||
    !["bridge", "compile", "match", "replacement"].includes(
      String(value.phase),
    ) ||
    typeof value.code !== "string" ||
    typeof value.message !== "string" ||
    value.message.length > 1_024
  ) {
    throw new Error("The Ruby bridge returned an invalid error record.");
  }
  return value as unknown as NativeError;
}

function requireIdentity(value: unknown): NativeIdentity {
  if (
    !isRecord(value) ||
    value.abi !== RUBY_BRIDGE_ABI_VERSION ||
    value.ok !== true ||
    !isRecord(value.identity) ||
    typeof value.identity.implementation !== "string" ||
    typeof value.identity.rubyVersion !== "string" ||
    typeof value.identity.platform !== "string" ||
    typeof value.identity.defaultExternalEncoding !== "string"
  ) {
    throw new Error("The Ruby bridge returned an invalid identity record.");
  }
  return value as unknown as NativeIdentity;
}

function isNativeSpan(
  value: unknown,
  maximumCodePoint: 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]) <= maximumCodePoint
  );
}

function requireExecution(
  value: unknown,
  request: RegexExecutionRequest,
  subjectCodePointLength: number,
  replacementRequest?: RegexReplacementRequest,
): NativeExecution {
  if (
    !isRecord(value) ||
    value.abi !== RUBY_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) ||
    typeof value.resultsTruncated !== "boolean" ||
    value.matches.length > request.maximumMatches
  ) {
    throw new Error("The Ruby bridge returned an invalid result record.");
  }

  const groupCount = Number(value.groupCount);
  const namedNumbers = 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 ||
      namedNumbers.has(Number(entry[0])) ||
      typeof entry[1] !== "string" ||
      entry[1].length === 0 ||
      entry[1].length > 1_024
    ) {
      throw new Error("The Ruby bridge returned invalid capture names.");
    }
    namedNumbers.add(Number(entry[0]));
  }

  let captureRows = 0;
  for (const match of value.matches) {
    if (
      !isRecord(match) ||
      !isNativeSpan(match.span, subjectCodePointLength) ||
      !Array.isArray(match.captures) ||
      match.captures.length !== groupCount
    ) {
      throw new Error("The Ruby bridge returned an invalid match record.");
    }
    captureRows += Math.max(1, groupCount);
    if (captureRows > request.maximumCaptureRows) {
      throw new Error("The Ruby bridge exceeded the capture-row limit.");
    }
    for (const capture of match.captures) {
      if (capture !== null && !isNativeSpan(capture, subjectCodePointLength)) {
        throw new Error("The Ruby bridge returned an invalid capture range.");
      }
    }
  }

  if (replacementRequest) {
    if (
      !Number.isSafeInteger(value.outputBytes) ||
      Number(value.outputBytes) < 0 ||
      Number(value.outputBytes) > replacementRequest.maximumOutputBytes ||
      typeof value.outputTruncated !== "boolean" ||
      value.output !== undefined
    ) {
      throw new Error(
        "The Ruby bridge returned invalid bounded replacement output.",
      );
    }
  } else if (
    value.output !== undefined ||
    value.outputBytes !== undefined ||
    value.outputTruncated !== undefined
  ) {
    throw new Error(
      "The Ruby bridge returned replacement data for an execution request.",
    );
  }
  return value as unknown as NativeExecution;
}

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

function validateRequest(
  request: RegexExecutionRequest,
  replacementRequest?: RegexReplacementRequest,
): void {
  if (request.flavour !== "ruby") {
    throw new Error(`Ruby adapter cannot execute ${request.flavour}.`);
  }
  if (
    request.flavourVersion !== undefined &&
    request.flavourVersion !== RUBY_FLAVOUR_VERSION
  ) {
    throw new RangeError(
      `Ruby 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 Ruby Regexp flags.");
  }
  const utf8Values = [
    ["pattern", request.pattern],
    ["subject", request.subject],
    ...(replacementRequest
      ? ([["replacement", replacementRequest.replacement]] as const)
      : []),
  ] as const;
  for (const [label, value] of utf8Values) {
    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(
          `Ruby Regexp ${label} contains an unpaired UTF-16 surrogate that its UTF-8 runtime cannot represent losslessly.`,
        );
      }
      if (first >= 0xdc00 && first <= 0xdfff) {
        throw new RangeError(
          `Ruby Regexp ${label} contains an unpaired UTF-16 surrogate that its UTF-8 runtime cannot represent losslessly.`,
        );
      }
    }
  }
  if (request.pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
    throw new RangeError(
      `Ruby Regexp patterns are limited to ${DEFAULT_REGEX_LIMITS.patternHardLengthUtf16.toLocaleString()} UTF-16 units.`,
    );
  }
  if (
    utf8ByteLength(request.subject) >
    DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
  ) {
    throw new RangeError(
      "Ruby Regexp 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(
      "Ruby Regexp result limits are outside the bounded contract.",
    );
  }
  if (request.options && Object.keys(request.options).length > 0) {
    throw new RangeError("The Ruby Regexp engine does not accept options.");
  }
  if (
    replacementRequest &&
    (replacementRequest.replacement.length >
      DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16 ||
      !Number.isSafeInteger(replacementRequest.maximumOutputBytes) ||
      replacementRequest.maximumOutputBytes < 1 ||
      replacementRequest.maximumOutputBytes >
        DEFAULT_REGEX_LIMITS.maximumReplacementOutputBytes)
  ) {
    throw new RangeError(
      "Ruby replacement or output limits are outside the bounded contract.",
    );
  }
}

export function rubyEngineInfo(runtimeIdentity?: string): RegexEngineInfo {
  return {
    flavour: "ruby",
    adapterVersion: APPLICATION_VERSION,
    engineName: "CRuby Regexp via ruby.wasm",
    engineVersion: RUBY_ENGINE_VERSION,
    runtimeVersion: [
      `ruby.wasm ${RUBY_WASM_PACKAGE_VERSION}`,
      "wasm32-wasi",
      runtimeIdentity,
    ]
      .filter(Boolean)
      .join(" · "),
    offsetUnit: "code-point",
    capabilities: {
      compilation: true,
      matching: true,
      replacement: true,
      namedCaptures: true,
      captureHistory: false,
      actualTrace: false,
      benchmark: false,
    },
  };
}

function diagnosticForError(error: NativeError): RegexDiagnostic {
  return {
    id: `ruby-${error.phase}-${error.code}`,
    source: "execution-engine",
    severity: "error",
    code: error.code,
    message: `Ruby Regexp ${error.phase} error: ${error.message}`,
    flavour: "ruby",
    provenance: "reported",
  };
}

function materializeMatches(
  native: NativeExecution,
  request: RegexExecutionRequest,
): {
  readonly matches: readonly RegexMatchResult[];
  readonly valuesTruncated: boolean;
} {
  const names = new Map(native.groupNames);
  const previews = new ValuePreviewBudget();
  const offsets = buildCodePointOffsetMap(request.subject);
  const matches = native.matches.map((match, index): RegexMatchResult => {
    const startUtf16 = codePointToUtf16(offsets, match.span[0]);
    const endUtf16 = codePointToUtf16(offsets, match.span[1]);
    const value = request.subject.slice(startUtf16, endUtf16);
    const preview = previews.take(value);
    const captures = match.captures.map((span, captureIndex): CaptureResult => {
      const groupNumber = captureIndex + 1;
      const groupName = names.get(groupNumber);
      if (span === null) {
        return {
          groupNumber,
          ...(groupName ? { groupName } : {}),
          status: "did-not-participate",
        };
      }
      const captureStartUtf16 = codePointToUtf16(offsets, span[0]);
      const captureEndUtf16 = codePointToUtf16(offsets, span[1]);
      const captureValue = request.subject.slice(
        captureStartUtf16,
        captureEndUtf16,
      );
      const capturePreview = previews.take(captureValue);
      return {
        groupNumber,
        ...(groupName ? { groupName } : {}),
        value: capturePreview.value,
        status: capturePreview.truncated
          ? "truncated"
          : captureValue.length === 0
            ? "matched-empty"
            : "participated",
        range: {
          startUtf16: captureStartUtf16,
          endUtf16: captureEndUtf16,
        },
        nativeRange: {
          start: span[0],
          end: span[1],
          unit: "code-point",
        },
      };
    });
    return {
      matchNumber: index + 1,
      value: preview.value,
      valueStatus: preview.truncated ? "truncated" : "complete",
      range: { startUtf16, endUtf16 },
      nativeRange: {
        start: match.span[0],
        end: match.span[1],
        unit: "code-point",
      },
      captures,
    };
  });
  return { matches, valuesTruncated: previews.truncated };
}

function resultDiagnostics(
  request: RegexExecutionRequest,
  native: NativeExecution,
  valuesTruncated: boolean,
  replacementRequest?: RegexReplacementRequest,
): readonly RegexDiagnostic[] {
  const diagnostics: RegexDiagnostic[] = [];
  if (native.resultsTruncated) {
    diagnostics.push({
      id: "ruby-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: "ruby",
      provenance: "derived",
    });
  }
  if (valuesTruncated) {
    diagnostics.push({
      id: "ruby-value-previews-truncated",
      source: "execution-engine",
      severity: "warning",
      code: "result-value-preview-limit",
      message:
        "Displayed Ruby Regexp values were clipped to bounded previews; exact code-point and UTF-16 ranges are retained.",
      flavour: "ruby",
      provenance: "derived",
    });
  }
  if (replacementRequest && native.outputTruncated) {
    diagnostics.push({
      id: "ruby-output-truncated",
      source: "execution-engine",
      severity: "warning",
      code: "replacement-output-limit",
      message: `Replacement preview was truncated at ${replacementRequest.maximumOutputBytes.toLocaleString()} UTF-8 bytes.`,
      flavour: "ruby",
      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 RubyEngineAdapter implements RegexEngineAdapter {
  readonly flavour = "ruby" as const;
  readonly #runtime: RubyVm;
  readonly #writeRequest: RubyRequestWriter;
  readonly #readOutput: RubyOutputReader;
  readonly #info: RegexEngineInfo;
  #bridge: RubyValue | undefined;
  #loadPromise: Promise<RegexEngineInfo> | undefined;
  #terminated = false;

  constructor(
    runtime: RubyVm,
    runtimeIdentity?: string,
    writeRequest: RubyRequestWriter = () => undefined,
    readOutput: RubyOutputReader = () => new Uint8Array(),
  ) {
    this.#runtime = runtime;
    this.#writeRequest = writeRequest;
    this.#readOutput = readOutput;
    this.#info = rubyEngineInfo(runtimeIdentity);
  }

  load(): Promise<RegexEngineInfo> {
    this.#loadPromise ??= Promise.resolve().then(() => {
      if (this.#terminated) {
        throw new Error("The Ruby engine adapter has been terminated.");
      }
      const identity = requireIdentity(
        this.#callBridge("identity", "", "", "", 1, 1, "", 1),
      );
      if (
        identity.identity.implementation !== "ruby" ||
        identity.identity.rubyVersion !== "4.0.0" ||
        identity.identity.platform !== "wasm32-wasi" ||
        identity.identity.defaultExternalEncoding !== "UTF-8"
      ) {
        throw new Error("The bundled Ruby runtime failed its identity test.");
      }
      return this.#info;
    });
    return this.#loadPromise;
  }

  async execute(request: RegexExecutionRequest): Promise<RegexExecutionResult> {
    await this.load();
    validateRequest(request);
    const start = performance.now();
    const flags = normalizedRubyFlags(request.flags, request.scanAll);
    const subjectCodePoints = buildCodePointOffsetMap(
      request.subject,
    ).codePointLength;
    const parsed = this.#callBridge(
      "execute",
      request.pattern,
      flags.effectiveFlags,
      request.subject,
      request.maximumMatches,
      request.maximumCaptureRows,
      "",
      1,
    );
    const error = parseNativeError(parsed);
    if (error) {
      return failedExecution(
        this.#info,
        flags,
        start,
        diagnosticForError(error),
      );
    }
    const native = requireExecution(parsed, request, subjectCodePoints);
    const materialized = materializeMatches(native, request);
    return {
      accepted: true,
      engine: this.#info,
      flags,
      matches: materialized.matches,
      diagnostics: resultDiagnostics(
        request,
        native,
        materialized.valuesTruncated,
      ),
      elapsedMs: performance.now() - start,
      truncated: native.resultsTruncated,
    };
  }

  async replace(
    request: RegexReplacementRequest,
  ): Promise<RegexReplacementResult> {
    await this.load();
    validateRequest(request, request);
    const start = performance.now();
    const flags = normalizedRubyFlags(request.flags, request.scanAll);
    const subjectCodePoints = buildCodePointOffsetMap(
      request.subject,
    ).codePointLength;
    const parsed = this.#callBridge(
      "replace",
      request.pattern,
      flags.effectiveFlags,
      request.subject,
      request.maximumMatches,
      request.maximumCaptureRows,
      request.replacement,
      request.maximumOutputBytes,
    );
    const error = parseNativeError(parsed);
    if (error) {
      return {
        execution: failedExecution(
          this.#info,
          flags,
          start,
          diagnosticForError(error),
        ),
        output: "",
        outputBytes: 0,
        outputTruncated: false,
        truncated: false,
      };
    }
    const native = requireExecution(
      parsed,
      request,
      subjectCodePoints,
      request,
    );
    const output = this.#readBoundedOutput(
      native.outputBytes ?? 0,
      request.maximumOutputBytes,
    );
    const materialized = materializeMatches(native, request);
    const execution: RegexExecutionResult = {
      accepted: true,
      engine: this.#info,
      flags,
      matches: materialized.matches,
      diagnostics: resultDiagnostics(
        request,
        native,
        materialized.valuesTruncated,
        request,
      ),
      elapsedMs: performance.now() - start,
      truncated: native.resultsTruncated,
    };
    return {
      execution,
      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 = undefined;
  }

  #bridgeValue(): RubyValue {
    if (this.#terminated) {
      throw new Error("The Ruby engine adapter has been terminated.");
    }
    this.#bridge ??= this.#runtime.eval(RUBY_BRIDGE_SOURCE) as RubyValue;
    if (
      !this.#bridge ||
      typeof this.#bridge.call !== "function" ||
      typeof this.#bridge.toString !== "function"
    ) {
      throw new Error(
        "The fixed Ruby bridge did not return a callable module.",
      );
    }
    return this.#bridge;
  }

  #callBridge(
    operation: string,
    pattern: string,
    flags: string,
    subject: string,
    maximumMatches: number,
    maximumCaptureRows: number,
    replacement: string,
    maximumOutputBytes: number,
  ): unknown {
    const fields = [
      operation,
      pattern,
      flags,
      subject,
      String(maximumMatches),
      String(maximumCaptureRows),
      replacement,
      String(maximumOutputBytes),
    ];
    this.#writeRequest(encodeRubyRequest(fields));
    const value = this.#bridgeValue().call("run") as RubyValue;
    if (!value || typeof value.toString !== "function") {
      throw new Error("The Ruby bridge returned a non-value result.");
    }
    const serialized = value.toString();
    if (serialized.length > MAXIMUM_SERIALIZED_RESPONSE_UTF16) {
      throw new Error(
        "The Ruby bridge response exceeds the serialization limit.",
      );
    }
    try {
      return JSON.parse(serialized) as unknown;
    } catch {
      throw new Error("The Ruby bridge returned malformed serialized data.");
    }
  }

  #readBoundedOutput(expectedBytes: number, maximumBytes: number): string {
    const payload = this.#readOutput();
    if (
      Object.prototype.toString.call(payload) !== "[object Uint8Array]" ||
      payload.byteLength !== expectedBytes ||
      payload.byteLength > maximumBytes
    ) {
      throw new Error(
        "The Ruby bridge output file exceeds its bounded contract.",
      );
    }
    let output: string;
    try {
      output = new TextDecoder("utf-8", { fatal: true }).decode(payload);
    } catch {
      throw new Error("The Ruby bridge output file is not valid UTF-8.");
    }
    if (utf8ByteLength(output) !== expectedBytes) {
      throw new Error("The Ruby bridge output file length is inconsistent.");
    }
    return output;
  }
}
