import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import type {
  RegexExecutionRequest,
  RegexReplacementRequest,
} from "../../../model/match";
import type {
  PortableBridgeRequest,
  PortableRegexBridge,
} from "../portable/PortableEngineAdapter";
import { PERL_FLAVOUR_VERSION, PerlEngineAdapter } from "./PerlEngineAdapter";

function identity(
  overrides: Readonly<Record<string, unknown>> = {},
): Readonly<Record<string, unknown>> {
  return {
    abi: 1,
    ok: true,
    identity: {
      bridge: "regex-tools-webperl-json",
      bridgeVersion: 1,
      engine: "Perl regular expressions",
      engineVersion: "5.28.1",
      perlVersion: "v5.28.1",
      webPerlVersion: "0.09-beta",
      offsetUnit: "code-point",
      replacementGrammar: "literal + $$ + $n + ${name}",
      replacementTransport: "fixed MEMFS binary output file",
      replacementParityOracle: "fixed native s/// fixtures",
      legacy: true,
      beta: true,
      selfTest: true,
      ...overrides,
    },
  };
}

class FakeBridge implements PortableRegexBridge {
  readonly result: unknown;
  readonly runtimeIdentity: Readonly<Record<string, unknown>>;
  request: PortableBridgeRequest | undefined;

  constructor(
    result: unknown,
    runtimeIdentity: Readonly<Record<string, unknown>> = identity(),
  ) {
    this.result = result;
    this.runtimeIdentity = runtimeIdentity;
  }

  async load(): Promise<Readonly<Record<string, unknown>>> {
    return this.runtimeIdentity;
  }

  async run(request: PortableBridgeRequest): Promise<unknown> {
    this.request = request;
    return this.result;
  }
}

function request(
  overrides: Partial<RegexExecutionRequest> = {},
): RegexExecutionRequest {
  return {
    flavour: "perl",
    flavourVersion: PERL_FLAVOUR_VERSION,
    pattern: "(?<word>\\w+)",
    flags: ["g", "u"],
    options: {},
    subject: "😀 café",
    captureMetadata: [
      {
        number: 1,
        name: "word",
        range: { startUtf16: 0, endUtf16: 13 },
        repeated: false,
      },
    ],
    scanAll: true,
    maximumMatches: 10,
    maximumCaptureRows: 100,
    ...overrides,
  };
}

describe("legacy WebPerl adapter", () => {
  it("normalizes actual Perl code-point spans to UTF-16", async () => {
    const bridge = new FakeBridge({
      abi: 1,
      ok: true,
      groupCount: 1,
      groupNames: [],
      matches: [{ span: [2, 6], captures: [[2, 6]] }],
      resultsTruncated: false,
    });
    const adapter = new PerlEngineAdapter(bridge, "test classic worker");

    const result = await adapter.execute(request());

    expect(result.engine).toEqual(
      expect.objectContaining({
        flavour: "perl",
        engineVersion: "5.28.1",
        offsetUnit: "code-point",
        runtimeVersion: expect.stringContaining(
          "WebPerl 0.09-beta · legacy beta",
        ),
      }),
    );
    expect(result.matches[0]).toMatchObject({
      value: "café",
      range: { startUtf16: 3, endUtf16: 7 },
      nativeRange: { start: 2, end: 6, unit: "code-point" },
      captures: [
        {
          groupNumber: 1,
          groupName: "word",
          value: "café",
          range: { startUtf16: 3, endUtf16: 7 },
        },
      ],
    });
    expect(bridge.request).toMatchObject({
      operation: "execute",
      flags: "gu",
    });
  });

  it("retains syntax-derived capture names on replacement match rows", async () => {
    const bridge = new FakeBridge({
      abi: 1,
      ok: true,
      groupCount: 1,
      groupNames: [],
      matches: [{ span: [0, 3], captures: [[0, 3]] }],
      resultsTruncated: false,
      output: "one",
      outputBytes: 3,
      outputTruncated: false,
    });
    const adapter = new PerlEngineAdapter(bridge);

    const result = await adapter.replace({
      ...request({ subject: "one" }),
      replacement: "${word}",
      maximumOutputBytes: 100,
    });

    expect(result.execution.matches[0]?.captures[0]).toMatchObject({
      groupNumber: 1,
      groupName: "word",
      value: "one",
    });
  });

  it("returns the fixed bridge's bounded replacement result", async () => {
    const bridge = new FakeBridge({
      abi: 1,
      ok: true,
      groupCount: 1,
      groupNames: [],
      matches: [{ span: [0, 3], captures: [[0, 3]] }],
      resultsTruncated: false,
      output: "word-$",
      outputBytes: 6,
      outputTruncated: false,
    });
    const adapter = new PerlEngineAdapter(bridge);
    const replacement: RegexReplacementRequest = {
      ...request({ subject: "one" }),
      replacement: "$1-$$",
      maximumOutputBytes: 100,
    };

    await expect(adapter.replace(replacement)).resolves.toMatchObject({
      output: "word-$",
      outputBytes: 6,
      outputTruncated: false,
      execution: { accepted: true },
    });
    expect(bridge.request).toMatchObject({
      operation: "replace",
      replacement: "$1-$$",
      maximumOutputBytes: 100,
    });
  });

  it("rejects mismatched runtimes, versions and lossy UTF-8 input", async () => {
    const invalidIdentity = new PerlEngineAdapter(
      new FakeBridge({}, identity({ selfTest: false })),
    );
    await expect(invalidIdentity.load()).rejects.toThrow("identity self-test");

    const adapter = new PerlEngineAdapter(
      new FakeBridge({
        abi: 1,
        ok: true,
        groupCount: 0,
        groupNames: [],
        matches: [],
        resultsTruncated: false,
      }),
    );
    await expect(
      adapter.execute(request({ flavourVersion: "current Perl" })),
    ).rejects.toThrow("unsupported");
    await expect(
      adapter.execute(request({ subject: "\ud800" })),
    ).rejects.toThrow("unpaired UTF-16 surrogate");
  });
});

describe("WebPerl source-channel audit", () => {
  const pack = resolve("public/engines/perl");
  const generatedLoader = readFileSync(resolve(pack, "emperl.js"), "utf8");
  const runtimeWorker = readFileSync(
    resolve(pack, "perl-runtime.worker.js"),
    "utf8",
  );
  const sourceHarness = readFileSync(
    resolve("engines/perl/regex_tools_bridge.pl"),
    "utf8",
  );
  const deployedHarness = readFileSync(
    resolve("public/engines/perl/regex_tools_bridge.pl"),
    "utf8",
  );

  it("keeps request values out of executable source", () => {
    expect(runtimeWorker).toContain(
      'var FIXED_INVOCATION = "RegexTools::run_request()"',
    );
    expect(runtimeWorker).toContain(
      'var OUTPUT_PATH = "/tmp/regex-tools-output.bin"',
    );
    expect(runtimeWorker).toContain(
      'new TextDecoder("utf-8", { fatal: true })',
    );
    expect(runtimeWorker).toContain("injectReplacementOutput(parsed");
    expect(runtimeWorker).not.toMatch(/\beval\s*\(/u);
    expect(sourceHarness).toContain("no re 'eval';");
    expect(sourceHarness).toContain("sub _tokenize_replacement");
    expect(sourceHarness).toContain("sub _resolve_named_spans");
    expect(sourceHarness).toContain("sub _append_replacement_tokens");
    expect(sourceHarness).toContain("sub _append_span");
    expect(sourceHarness).not.toContain("sub _capture_values");
    expect(sourceHarness).not.toContain("sub _expand_replacement");
    expect(sourceHarness).not.toContain("$response->{output}");
    expect(sourceHarness).not.toMatch(/\beval\s+(?:\\?["']|\$)/u);
    expect(sourceHarness).not.toContain("use WebPerl");
    expect(deployedHarness).toBe(sourceHarness);
  });

  it("pins the generated loader's sole dynamic-code site to unused WebPerl JS interop", () => {
    expect(generatedLoader.match(/\beval\s*\(/gu)).toHaveLength(1);
    expect(generatedLoader).toContain("var rv=eval(code)");
    expect(generatedLoader).not.toContain("new Function");
  });

  it("ships the exact legacy identity, licences, and checksummed runtime pack", () => {
    const metadata = JSON.parse(
      readFileSync(resolve(pack, "engine-metadata.json"), "utf8"),
    ) as {
      engineVersion: string;
      status: string;
      source: {
        tag: string;
        commitSha1: string;
        license: string;
      };
      prebuilt: {
        archiveSha256: string;
        runtimeAssetsCopiedByteForByte: boolean;
      };
      securityAudit: {
        generatedLoaderJavaScriptEvalSites: number;
        generatedLoaderNewFunctionSites: number;
      };
    };
    expect(metadata).toMatchObject({
      engineVersion: "5.28.1",
      status: "legacy-beta-compatibility-runtime",
      source: {
        tag: "v0.09-beta",
        commitSha1: "6f2173d29a2c2e3536e1de75ff5d291ae96ab348",
        license: "GPL-1.0-or-later OR Artistic-1.0-Perl",
      },
      prebuilt: {
        archiveSha256:
          "5f441249217e90ab378c666f473d4206ab4f44907f6bb0aa8d70834bc38c40dc",
        runtimeAssetsCopiedByteForByte: true,
      },
      securityAudit: {
        generatedLoaderJavaScriptEvalSites: 1,
        generatedLoaderNewFunctionSites: 0,
      },
    });
    const checksums = readFileSync(resolve(pack, "SHA256SUMS"), "utf8");
    for (const line of checksums.trim().split("\n")) {
      const [expected, name] = line.split(/ {2}/u);
      const value = readFileSync(resolve(pack, name ?? ""));
      expect(createHash("sha256").update(value).digest("hex"), name).toBe(
        expected,
      );
    }
    expect(
      readFileSync(
        resolve(pack, "LICENSE-Devel-StackTrace-Artistic-2.0.txt"),
        "utf8",
      ),
    ).toContain("The Artistic License 2.0");
  });
});
