import { describe, expect, it } from "vitest";
import type {
  RegexExecutionRequest,
  RegexReplacementRequest,
} from "../../../model/match";
import {
  RubyEngineAdapter,
  RUBY_BRIDGE_ABI_VERSION,
  RUBY_ENGINE_VERSION,
  RUBY_FLAVOUR_VERSION,
} from "./RubyEngineAdapter";
import type { RubyVm } from "./ruby-module";

type ResponseRecord = Record<string, unknown>;

class FakeRubyValue {
  readonly value: unknown;
  readonly responder?: () => ResponseRecord;

  constructor(value: unknown, responder?: () => ResponseRecord) {
    this.value = value;
    this.responder = responder;
  }

  call(): FakeRubyValue {
    if (!this.responder) throw new Error("Fake value is not callable.");
    return new FakeRubyValue(this.responder());
  }

  toJS(): unknown {
    return this.value;
  }

  toString(): string {
    return typeof this.value === "string"
      ? this.value
      : JSON.stringify(this.value);
  }
}

class FakeRubyVm {
  readonly responder: (operation: string) => ResponseRecord;
  evalCalls = 0;
  operation = "";

  constructor(responder: (operation: string) => ResponseRecord) {
    this.responder = responder;
  }

  eval(source: string): FakeRubyValue {
    void source;
    this.evalCalls += 1;
    return new FakeRubyValue(undefined, () => this.responder(this.operation));
  }
}

function requestOperation(payload: Uint8Array): string {
  const length = new DataView(
    payload.buffer,
    payload.byteOffset,
    payload.byteLength,
  ).getUint32(8, false);
  return new TextDecoder().decode(payload.subarray(12, 12 + length));
}

function runtime(
  responder: (operation: string) => ResponseRecord,
  output = "",
): {
  readonly fake: FakeRubyVm;
  readonly adapter: RubyEngineAdapter;
} {
  const fake = new FakeRubyVm(responder);
  return {
    fake,
    adapter: new RubyEngineAdapter(
      fake as unknown as RubyVm,
      "unit-test runtime",
      (payload) => {
        fake.operation = requestOperation(payload);
      },
      () => new TextEncoder().encode(output),
    ),
  };
}

function identity(): ResponseRecord {
  return {
    abi: RUBY_BRIDGE_ABI_VERSION,
    ok: true,
    identity: {
      implementation: "ruby",
      rubyVersion: "4.0.0",
      platform: "wasm32-wasi",
      defaultExternalEncoding: "UTF-8",
    },
  };
}

function request(
  overrides: Partial<RegexExecutionRequest> = {},
): RegexExecutionRequest {
  return {
    flavour: "ruby",
    flavourVersion: RUBY_FLAVOUR_VERSION,
    pattern: "(?<emoji>😀)(x)?",
    flags: ["g"],
    subject: "a😀b",
    captureMetadata: [],
    scanAll: false,
    maximumMatches: 100,
    maximumCaptureRows: 1_000,
    ...overrides,
  };
}

describe("CRuby WebAssembly adapter", () => {
  it("normalizes authoritative code-point ranges and capture names", async () => {
    const { fake, adapter } = runtime((operation) =>
      operation === "identity"
        ? identity()
        : {
            abi: RUBY_BRIDGE_ABI_VERSION,
            ok: true,
            groupCount: 2,
            groupNames: [[1, "emoji"]],
            matches: [
              {
                span: [1, 2],
                captures: [[1, 2], null],
              },
            ],
            resultsTruncated: false,
          },
    );

    await expect(adapter.load()).resolves.toEqual(
      expect.objectContaining({
        flavour: "ruby",
        engineVersion: RUBY_ENGINE_VERSION,
        runtimeVersion: expect.stringContaining("wasm32-wasi"),
        offsetUnit: "code-point",
      }),
    );
    const result = await adapter.execute(request());

    expect(result.accepted).toBe(true);
    expect(result.matches[0]).toEqual(
      expect.objectContaining({
        value: "😀",
        range: { startUtf16: 1, endUtf16: 3 },
        nativeRange: { start: 1, end: 2, unit: "code-point" },
      }),
    );
    expect(result.matches[0]?.captures).toEqual([
      expect.objectContaining({
        groupNumber: 1,
        groupName: "emoji",
        value: "😀",
        range: { startUtf16: 1, endUtf16: 3 },
      }),
      { groupNumber: 2, status: "did-not-participate" },
    ]);
    expect(fake.evalCalls).toBe(1);
  });

  it("normalizes scan-all flags and reported compile errors", async () => {
    const { adapter } = runtime((operation) =>
      operation === "identity"
        ? identity()
        : {
            abi: RUBY_BRIDGE_ABI_VERSION,
            ok: false,
            phase: "compile",
            code: "compile-error",
            message: "unmatched parenthesis",
          },
    );

    const result = await adapter.execute(
      request({ flags: ["i"], scanAll: true }),
    );
    expect(result.accepted).toBe(false);
    expect(result.flags).toEqual({
      userFlags: "i",
      effectiveFlags: "gi",
      internallyAddedIndicesFlag: false,
      internallyAddedGlobalFlag: true,
    });
    expect(result.diagnostics[0]).toEqual(
      expect.objectContaining({
        flavour: "ruby",
        code: "compile-error",
      }),
    );
  });

  it("returns bounded native String#gsub output", async () => {
    const { adapter } = runtime((operation) => {
      if (operation === "identity") return identity();
      return {
        abi: RUBY_BRIDGE_ABI_VERSION,
        ok: true,
        groupCount: 1,
        groupNames: [[1, "emoji"]],
        matches: [{ span: [1, 2], captures: [[1, 2]] }],
        resultsTruncated: false,
        outputBytes: 7,
        outputTruncated: true,
      };
    }, "a[😀]");
    const replacementRequest: RegexReplacementRequest = {
      ...request(),
      replacement: "[\\k<emoji>]",
      maximumOutputBytes: 7,
    };

    const result = await adapter.replace(replacementRequest);

    expect(result.output).toBe("a[😀]");
    expect(result.outputBytes).toBe(7);
    expect(result.outputTruncated).toBe(true);
    expect(result.execution.diagnostics).toEqual([
      expect.objectContaining({ code: "replacement-output-limit" }),
    ]);
    adapter.terminate();
    await expect(adapter.execute(request())).rejects.toThrow("terminated");
  });

  it("rejects mismatched profiles and malformed bounded requests", async () => {
    const { adapter } = runtime(identity);

    await expect(
      adapter.execute(request({ flavourVersion: "CRuby 3.4" })),
    ).rejects.toThrow(/version .* unsupported/u);
    await expect(
      adapter.execute(request({ flavour: "python" })),
    ).rejects.toThrow("cannot execute python");
    await expect(adapter.execute(request({ flags: ["s"] }))).rejects.toThrow(
      "unsupported Ruby Regexp flags",
    );
    await expect(
      adapter.execute(request({ options: { ascii: true } })),
    ).rejects.toThrow("does not accept options");
    await expect(
      adapter.execute(request({ maximumCaptureRows: 0 })),
    ).rejects.toThrow("outside the bounded contract");
    await expect(
      adapter.replace({
        ...request(),
        replacement: "x",
        maximumOutputBytes: 0,
      }),
    ).rejects.toThrow("outside the bounded contract");
  });

  it("rejects lone surrogates before the UTF-8 request file is written", async () => {
    const { adapter } = runtime(identity);
    const high = String.fromCharCode(0xd800);
    const low = String.fromCharCode(0xdc00);

    await expect(
      adapter.execute(request({ pattern: `a${high}` })),
    ).rejects.toThrow(/pattern contains an unpaired UTF-16 surrogate/u);
    await expect(
      adapter.execute(request({ subject: `a${low}` })),
    ).rejects.toThrow(/subject contains an unpaired UTF-16 surrogate/u);
    await expect(
      adapter.replace({
        ...request(),
        replacement: high,
        maximumOutputBytes: 100,
      }),
    ).rejects.toThrow(/replacement contains an unpaired UTF-16 surrogate/u);
  });

  it("fails closed on inline, stale, or malformed output-file data", async () => {
    const response = (operation: string): ResponseRecord =>
      operation === "identity"
        ? identity()
        : {
            abi: RUBY_BRIDGE_ABI_VERSION,
            ok: true,
            groupCount: 0,
            groupNames: [],
            matches: [],
            resultsTruncated: false,
            outputBytes: 1,
            outputTruncated: false,
          };
    const requestValue: RegexReplacementRequest = {
      ...request({ pattern: "z", subject: "" }),
      replacement: "x",
      maximumOutputBytes: 100,
    };

    await expect(
      runtime(response, "xx").adapter.replace(requestValue),
    ).rejects.toThrow("output file exceeds its bounded contract");
    await expect(
      runtime(
        (operation) =>
          operation === "identity"
            ? identity()
            : { ...response(operation), output: "x" },
        "x",
      ).adapter.replace(requestValue),
    ).rejects.toThrow("invalid bounded replacement output");
  });

  it("fails closed on malformed native ranges", async () => {
    const { adapter } = runtime((operation) =>
      operation === "identity"
        ? identity()
        : {
            abi: RUBY_BRIDGE_ABI_VERSION,
            ok: true,
            groupCount: 0,
            groupNames: [],
            matches: [{ span: [0, 99], captures: [] }],
            resultsTruncated: false,
          },
    );

    await expect(adapter.execute(request())).rejects.toThrow(
      "invalid match record",
    );
  });
});
