// @vitest-environment node

import { createRequire } from "node:module";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
  PHP_ENGINE_VERSION,
  PHP_FLAVOUR_VERSION,
  PhpEngineAdapter,
} from "../../src/regex/execution/adapters/php/PhpEngineAdapter";
import {
  PHP,
  loadPHPRuntime,
  type PhpLoaderModule,
} from "../../src/regex/execution/adapters/php/php-runtime";
import { PhpWasmBridge } from "../../src/regex/execution/adapters/php/PhpWasmBridge";
import type {
  RegexExecutionRequest,
  RegexReplacementRequest,
} from "../../src/regex/model/match";

const pack = path.resolve("public/engines/php");
const mutableGlobal = globalThis as unknown as Record<string, unknown>;
let previousRequire: unknown;
let hadRequire = false;
let php: InstanceType<typeof PHP>;
let bridge: PhpWasmBridge;
let adapter: PhpEngineAdapter;

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

function replacement(
  pattern: string,
  subject: string,
  replacementText: string,
  overrides: Partial<RegexReplacementRequest> = {},
): RegexReplacementRequest {
  return {
    ...request(pattern, subject),
    replacement: replacementText,
    maximumOutputBytes: 1_024,
    ...overrides,
  };
}

beforeAll(async () => {
  hadRequire = Object.hasOwn(mutableGlobal, "require");
  previousRequire = mutableGlobal.require;
  mutableGlobal.require = createRequire(import.meta.url);
  const loader = (await import(
    `${pathToFileURL(path.join(pack, "php-loader.mjs")).href}?conformance`
  )) as PhpLoaderModule;
  const wasmBinary = await readFile(path.join(pack, "php.wasm"));
  const runtimeId = await loadPHPRuntime(loader, {
    wasmBinary,
    phpWasmAsyncMode: "asyncify",
    debug: false,
    print: () => undefined,
    printErr: () => undefined,
  });
  php = new PHP(runtimeId);
  bridge = new PhpWasmBridge(php);
  adapter = new PhpEngineAdapter(bridge, "Node.js conformance");
  await adapter.load();
}, 30_000);

afterAll(() => {
  adapter?.terminate();
  try {
    php?.exit(0);
  } catch {
    // Emscripten may signal its normal exit as an exception.
  }
  if (hadRequire) mutableGlobal.require = previousRequire;
  else delete mutableGlobal.require;
});

describe("PHP 8.5.8 preg / PCRE2 10.44 conformance", () => {
  it("loads the exact Asyncify runtime and native self-test identity", async () => {
    await expect(adapter.load()).resolves.toEqual(
      expect.objectContaining({
        flavour: "php",
        engineName: "PHP preg (PCRE2)",
        engineVersion: PHP_ENGINE_VERSION,
        runtimeVersion: expect.stringContaining(
          "@php-wasm/web-8-5 3.1.46 · Asyncify",
        ),
        offsetUnit: "utf8-byte",
      }),
    );
  });

  it("returns PREG_OFFSET_CAPTURE byte ranges and named/optional captures", async () => {
    const result = await adapter.execute(
      request("(?<emoji>😀+)([A-Z]+)?", "x😀😀AB 😀"),
    );

    expect(result.accepted).toBe(true);
    expect(
      result.matches.map(({ nativeRange, range }) => ({ nativeRange, range })),
    ).toEqual([
      {
        nativeRange: { start: 1, end: 11, unit: "utf8-byte" },
        range: { startUtf16: 1, endUtf16: 7 },
      },
      {
        nativeRange: { start: 12, end: 16, unit: "utf8-byte" },
        range: { startUtf16: 8, endUtf16: 10 },
      },
    ]);
    expect(result.matches[0]?.captures).toEqual([
      expect.objectContaining({
        groupNumber: 1,
        groupName: "emoji",
        value: "😀😀",
        nativeRange: { start: 1, end: 9, unit: "utf8-byte" },
      }),
      expect.objectContaining({ groupNumber: 2, value: "AB" }),
    ]);
    expect(result.matches[1]?.captures[1]).toEqual({
      groupNumber: 2,
      status: "did-not-participate",
    });
  });

  it("reproduces preg_match_all empty-match retry and bounded search", async () => {
    const global = await adapter.execute(request("(?:|a)", "a"));
    expect(
      global.matches.map(({ value, nativeRange }) => [value, nativeRange]),
    ).toEqual([
      ["", { start: 0, end: 0, unit: "utf8-byte" }],
      ["a", { start: 0, end: 1, unit: "utf8-byte" }],
      ["", { start: 1, end: 1, unit: "utf8-byte" }],
    ]);

    const search = await adapter.execute(
      request("a", "a a", { flags: ["u"], scanAll: false }),
    );
    expect(search.matches).toHaveLength(1);
  });

  it("passes PHP preg modifiers to PCRE2 without reinterpretation", async () => {
    const cases = [
      ["a", "A", ["i", "u"], "A"],
      ["^b$", "a\nb\nc", ["m", "u"], "b"],
      ["a.b", "a\nb", ["s", "u"], "a\nb"],
      ["a # note\n b", "ab", ["x", "u"], "ab"],
      ["a+", "aaa", ["U", "u"], "a"],
    ] as const;
    for (const [pattern, subject, flags, expected] of cases) {
      const result = await adapter.execute(
        request(pattern, subject, { flags: [...flags] }),
      );
      expect(result.matches[0]?.value, flags.join("")).toBe(expected);
    }

    const anchored = await adapter.execute(
      request("b", "ab", { flags: ["A", "u"] }),
    );
    expect(anchored.matches).toHaveLength(0);
    const dollarEndOnly = await adapter.execute(
      request("a$", "a\n", { flags: ["D", "u"] }),
    );
    expect(dollarEndOnly.matches).toHaveLength(0);
    const noAutoCapture = await adapter.execute(
      request("(a)(?<letter>b)", "ab", { flags: ["n", "u"] }),
    );
    expect(noAutoCapture.matches[0]?.captures).toEqual([
      expect.objectContaining({
        groupNumber: 1,
        groupName: "letter",
        value: "b",
      }),
    ]);
    const duplicateNames = await adapter.execute(
      request("(?<part>a)(?<part>b)", "ab", { flags: ["J", "u"] }),
    );
    expect(duplicateNames.matches[0]?.captures).toEqual([
      expect.objectContaining({
        groupNumber: 1,
        groupName: "part",
        value: "a",
      }),
      expect.objectContaining({
        groupNumber: 2,
        groupName: "part",
        value: "b",
      }),
    ]);
  });

  it("selects or escapes a delimiter without changing pattern semantics", async () => {
    const result = await adapter.execute(
      request("[~#%!@;:=,`/]+", "x~#%!@;:=,`/y"),
    );
    expect(result.matches[0]?.value).toBe("~#%!@;:=,`/");

    const alreadyEscaped = await adapter.execute(request("\\~+", "~~~"));
    expect(alreadyEscaped.matches[0]?.value).toBe("~~~");
  });

  it("matches PHP 8.5.8 preg_replace numbered and escape rules", async () => {
    const global = await adapter.replace(
      replacement("([a-z]+)-(\\d+)", "a-1 b-22", "$2:$1/${1}"),
    );
    expect(global.execution.accepted).toBe(true);
    expect(global.output).toBe("1:a/a 22:b/b");

    const first = await adapter.replace(
      replacement("a", "a a", "x", { flags: ["u"] }),
    );
    expect(first.output).toBe("x a");

    const inertPhp = await adapter.replace(
      replacement(
        "(x)",
        '<?php "x"; ?>',
        "'; throw new Exception('not source'); //$1",
      ),
    );
    expect(inertPhp.execution.accepted).toBe(true);
    expect(inertPhp.output).toContain("not source");

    const templateCases = [
      ["$2:$1/${1}", "b:a/a :a/a"],
      ["\\1", "a a"],
      ["\\\\1", "\\1 \\1"],
      ["\\$1", "$1 $1"],
      ["$99", " "],
      ["$123", "3 3"],
      ["${1x}", "${1x} ${1x}"],
    ] as const;
    for (const [template, expected] of templateCases) {
      const result = await adapter.replace(
        replacement("(a)(b)?", "ab a", template),
      );
      expect(result.output, JSON.stringify(template)).toBe(expected);
    }
  });

  it("enforces match/capture/output bounds on UTF-8 boundaries", async () => {
    const limited = await adapter.execute(
      request("a", "aaa", { maximumMatches: 1 }),
    );
    expect(limited.matches).toHaveLength(1);
    expect(limited.truncated).toBe(true);

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

    const output = await adapter.replace(
      replacement(".", "abc", "😀", { maximumOutputBytes: 5 }),
    );
    expect(output.output).toBe("😀");
    expect(output.outputBytes).toBe(4);
    expect(output.outputTruncated).toBe(true);

    const partial = await adapter.replace(
      replacement("a", "aa", "x", { maximumMatches: 1 }),
    );
    expect(partial.execution.truncated).toBe(true);
    expect(partial.output).toBe("xa");

    const expanded = await adapter.replace(
      replacement("a", "a", "😀".repeat(32_768), {
        maximumOutputBytes: 5,
      }),
    );
    expect(expanded.output).toBe("😀");
    expect(expanded.outputBytes).toBe(4);
    expect(expanded.outputTruncated).toBe(true);
  });

  it("reports native compile failures and lossy byte-mode offsets", async () => {
    const compile = await adapter.execute(request("(", ""));
    expect(compile.accepted).toBe(false);
    expect(compile.diagnostics[0]).toEqual(
      expect.objectContaining({
        code: "compile-error",
        range: { startUtf16: 1, endUtf16: 1 },
      }),
    );

    const byteMode = await adapter.execute(request(".", "é", { flags: ["g"] }));
    expect(byteMode.accepted).toBe(false);
    expect(byteMode.diagnostics[0]).toEqual(
      expect.objectContaining({ code: "unaligned-utf8-offset" }),
    );
  });

  it("rejects lone UTF-16 surrogates before entering PHP", async () => {
    await expect(adapter.execute(request(".", "\ud800"))).rejects.toThrow(
      /unpaired UTF-16 surrogate/u,
    );
  });

  it("enforces UTF-16 pattern and replacement limits inside the PHP bridge", async () => {
    const common = {
      abi: 1 as const,
      flags: "u",
      options: {},
      subject: "",
      scanAll: false,
      maximumMatches: 1,
      maximumCaptureRows: 1,
    };
    await expect(
      bridge.run({
        ...common,
        operation: "execute",
        pattern: "😀".repeat(32_769),
      }),
    ).resolves.toEqual(
      expect.objectContaining({
        ok: false,
        code: "pattern-limit",
      }),
    );
    await expect(
      bridge.run({
        ...common,
        operation: "replace",
        pattern: "",
        replacement: "😀".repeat(32_769),
        maximumOutputBytes: 1,
      }),
    ).resolves.toEqual(
      expect.objectContaining({
        ok: false,
        code: "replacement-limit",
      }),
    );
  });
});
