// @vitest-environment node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { File, PreopenDirectory } from "@bjorn3/browser_wasi_shim";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
  RubyEngineAdapter,
  RUBY_ENGINE_VERSION,
  RUBY_FLAVOUR_VERSION,
} from "../../src/regex/execution/adapters/ruby/RubyEngineAdapter";
import { DefaultRubyVM } from "../../src/regex/execution/adapters/ruby/ruby-module";
import type {
  RegexExecutionRequest,
  RegexReplacementRequest,
} from "../../src/regex/model/match";

const runtimeFile = path.resolve(
  process.cwd(),
  "public",
  "engines",
  "ruby",
  "ruby.wasm",
);

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

describe("CRuby 4.0.0 Regexp conformance through ruby.wasm", () => {
  let adapter: RubyEngineAdapter;

  beforeAll(async () => {
    const module = await WebAssembly.compile(await readFile(runtimeFile));
    const { vm, wasi } = await DefaultRubyVM(module, {
      consolePrint: false,
    });
    const requestFile = new File([]);
    const root = wasi.fds[3];
    if (!(root instanceof PreopenDirectory)) {
      throw new Error("Ruby conformance runtime has no writable root.");
    }
    root.dir.contents.set("regex-tools-request.bin", requestFile);
    const outputFile = new File([]);
    root.dir.contents.set("regex-tools-output.bin", outputFile);
    adapter = new RubyEngineAdapter(
      vm,
      "Node conformance runtime",
      (payload) => {
        requestFile.data = payload;
      },
      () => outputFile.data,
    );
    await adapter.load();
  }, 30_000);

  afterAll(() => {
    adapter?.terminate();
  });

  it("reports the exact CRuby, ruby.wasm, platform and offset identity", async () => {
    await expect(adapter.load()).resolves.toEqual(
      expect.objectContaining({
        flavour: "ruby",
        engineName: "CRuby Regexp via ruby.wasm",
        engineVersion: RUBY_ENGINE_VERSION,
        runtimeVersion: expect.stringMatching(
          /ruby\.wasm 2\.9\.3-2\.9\.4 · wasm32-wasi/u,
        ),
        offsetUnit: "code-point",
      }),
    );
  });

  it("uses native Ruby named groups, intersections and optional captures", async () => {
    const result = await adapter.execute(
      request({
        pattern: "(?<word>[a-z&&[^aeiou]]+)(?:-(?<number>\\d+))?",
        flags: ["g", "i"],
        subject: "BCD-12 xyz",
      }),
    );

    expect(result.accepted).toBe(true);
    expect(result.matches.map((match) => match.value)).toEqual([
      "BCD-12",
      "xyz",
    ]);
    expect(result.matches[0]?.captures).toEqual([
      expect.objectContaining({
        groupNumber: 1,
        groupName: "word",
        value: "BCD",
      }),
      expect.objectContaining({
        groupNumber: 2,
        groupName: "number",
        value: "12",
      }),
    ]);
    expect(result.matches[1]?.captures).toEqual([
      expect.objectContaining({
        groupNumber: 1,
        groupName: "word",
        value: "xyz",
      }),
      {
        groupNumber: 2,
        groupName: "number",
        status: "did-not-participate",
      },
    ]);
  });

  it("normalizes astral and zero-length code-point offsets to UTF-16", async () => {
    const result = await adapter.execute(
      request({
        pattern: "(?=.)",
        subject: "😀a",
      }),
    );

    expect(
      result.matches.map((match) => ({
        native: match.nativeRange,
        editor: match.range,
        value: match.value,
      })),
    ).toEqual([
      {
        native: { start: 0, end: 0, unit: "code-point" },
        editor: { startUtf16: 0, endUtf16: 0 },
        value: "",
      },
      {
        native: { start: 1, end: 1, unit: "code-point" },
        editor: { startUtf16: 2, endUtf16: 2 },
        value: "",
      },
    ]);
  });

  it("implements Ruby g, i, m and x with scan-all normalization", async () => {
    const combined = await adapter.execute(
      request({
        pattern: " a . b ",
        flags: ["i", "m", "x"],
        subject: "z A\nB q",
        scanAll: true,
      }),
    );

    expect(combined.accepted).toBe(true);
    expect(combined.flags.effectiveFlags).toBe("gimx");
    expect(combined.flags.internallyAddedGlobalFlag).toBe(true);
    expect(combined.matches.map((match) => match.value)).toEqual(["A\nB"]);
  });

  it("uses native String#gsub/sub named, numbered and zero-length replacement", async () => {
    const replacementRequest: RegexReplacementRequest = {
      ...request({
        pattern: "(?<word>[a-z]+)-(?<number>\\d+)",
        subject: "a-1 b-22",
      }),
      replacement: "\\k<word>[\\k<number>]",
      maximumOutputBytes: 1_024,
    };
    const replaced = await adapter.replace(replacementRequest);

    expect(replaced.execution.accepted).toBe(true);
    expect(replaced.output).toBe("a[1] b[22]");
    expect(replaced.outputTruncated).toBe(false);

    const numbered = await adapter.replace({
      ...request({
        pattern: "([a-z]+)-(\\d+)",
        subject: "a-1 b-22",
      }),
      replacement: "\\1[\\2]",
      maximumOutputBytes: 1_024,
    });
    expect(numbered.output).toBe("a[1] b[22]");

    const firstOnly = await adapter.replace({
      ...request({
        pattern: "(?<word>[a-z]+)",
        flags: [],
        subject: "a b",
      }),
      replacement: "<\\k<word>>",
      maximumOutputBytes: 1_024,
    });
    expect(firstOnly.output).toBe("<a> b");

    const zeroLength = await adapter.replace({
      ...request({
        pattern: "(?=.)",
        subject: "😀a",
      }),
      replacement: "|",
      maximumOutputBytes: 1_024,
    });
    expect(zeroLength.output).toBe("|😀|a");
  });

  it("matches CRuby replacement grammar without materializing expanded output", async () => {
    const base = request({
      pattern: "(a)(b)?",
      subject: "ab a",
    });
    const cases = [
      ["\\0", "ab a"],
      ["\\&", "ab a"],
      ["\\1", "a a"],
      ["\\2", "b "],
      ["\\9", " "],
      ["\\+", "b a"],
      ["\\\\", "\\ \\"],
      ["\\q", "\\q \\q"],
      ["\\`", " ab "],
      ["\\'", " a "],
      ["$1", "$1 $1"],
    ] as const;
    for (const [template, expected] of cases) {
      const result = await adapter.replace({
        ...base,
        replacement: template,
        maximumOutputBytes: 1_024,
      });
      expect(result.execution.accepted, template).toBe(true);
      expect(result.output, template).toBe(expected);
      expect(result.outputTruncated, template).toBe(false);
    }

    const namedDisablesNumeric = await adapter.replace({
      ...request({
        pattern: "(?<word>a)",
        subject: "a",
      }),
      replacement: "\\1",
      maximumOutputBytes: 1_024,
    });
    expect(namedDisablesNumeric.output).toBe("");
  });

  it("stops expansion at the byte bound and replacement at result caps", async () => {
    const boundedExpansion = await adapter.replace({
      ...request({
        pattern: "(a+)",
        subject: "a".repeat(4_096),
      }),
      replacement: "\\1".repeat(32_768),
      maximumOutputBytes: 5,
    });
    expect(boundedExpansion.execution.accepted).toBe(true);
    expect(boundedExpansion.output).toBe("aaaaa");
    expect(boundedExpansion.outputBytes).toBe(5);
    expect(boundedExpansion.outputTruncated).toBe(true);

    const partial = await adapter.replace({
      ...request({
        pattern: "a",
        subject: "aaa",
        maximumMatches: 1,
      }),
      replacement: "x",
      maximumOutputBytes: 1_024,
    });
    expect(partial.execution.matches).toHaveLength(1);
    expect(partial.execution.truncated).toBe(true);
    expect(partial.output).toBe("xaa");
    expect(partial.outputTruncated).toBe(false);
  });

  it("bounds UTF-8 output without splitting an astral scalar", async () => {
    const result = await adapter.replace({
      ...request({ pattern: ".", subject: "abc" }),
      replacement: "😀",
      maximumOutputBytes: 5,
    });

    expect(result.execution.accepted).toBe(true);
    expect(result.output).toBe("😀");
    expect(result.outputBytes).toBe(4);
    expect(result.outputTruncated).toBe(true);
    expect(result.truncated).toBe(true);
  });

  it("reports native compile and replacement errors", async () => {
    const compileError = await adapter.execute(request({ pattern: "(" }));
    expect(compileError.accepted).toBe(false);
    expect(compileError.diagnostics[0]?.code).toBe("compile-error");

    const replacementError = await adapter.replace({
      ...request({ pattern: "(?<word>a)" }),
      replacement: "\\k<missing>",
      maximumOutputBytes: 1_024,
    });
    expect(replacementError.execution.accepted).toBe(false);
    expect(replacementError.execution.diagnostics[0]?.code).toBe(
      "replacement-error",
    );
  });

  it("rejects lone UTF-16 surrogates before entering ruby.wasm", async () => {
    await expect(
      adapter.execute(request({ subject: `a${String.fromCharCode(0xd800)}` })),
    ).rejects.toThrow(/unpaired UTF-16 surrogate/u);
  });

  it("enforces authoritative match and capture-row collection caps", async () => {
    const matchCap = await adapter.execute(
      request({
        pattern: "a",
        subject: "aaa",
        maximumMatches: 1,
      }),
    );
    expect(matchCap.matches).toHaveLength(1);
    expect(matchCap.truncated).toBe(true);

    const rowCap = await adapter.execute(
      request({
        pattern: "(a)(b)",
        subject: "ab",
        maximumCaptureRows: 1,
      }),
    );
    expect(rowCap.matches).toHaveLength(0);
    expect(rowCap.truncated).toBe(true);
  });
});
