import { describe, expect, it } from "vitest";
import { replaceEcmaScript } from "../execution/adapters/ecmascript/EcmaScriptEngineAdapter";
import type { RegexReplacementResult } from "../model/match";
import type {
  CaptureDefinition,
  ReplacementSyntaxResult,
} from "../model/syntax";
import { parseEcmaScriptReplacement } from "../syntax/providers/ecmascript/replacement";
import { parsePcre2Replacement } from "../syntax/providers/pcre2/replacement";
import {
  buildReplacementPreview,
  MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS,
  MAXIMUM_REPLACEMENT_PREVIEW_MATCHES,
  MAXIMUM_REPLACEMENT_PREVIEW_TOKEN_EVALUATIONS,
} from "./replacement-preview";

const captures: readonly CaptureDefinition[] = [
  {
    number: 1,
    name: "letter",
    range: { startUtf16: 0, endUtf16: 12 },
    repeated: false,
  },
];

function result(
  subject: string,
  replacement = "[$<letter>]",
  pattern = "(?<letter>[a-z])",
): RegexReplacementResult {
  return replaceEcmaScript({
    flavour: "ecmascript",
    pattern,
    flags: ["g"],
    subject,
    replacement,
    captureMetadata: captures,
    scanAll: false,
    maximumMatches: 10_000,
    maximumCaptureRows: 100_000,
    maximumOutputBytes: 64 * 1024 * 1024,
  });
}

describe("buildReplacementPreview", () => {
  it("maps PCRE2 whole-subject and highest-participating-capture tokens", () => {
    const subject = "ab";
    const nativeShape = result(subject, "$2", "(a)(b)");
    const syntax = parsePcre2Replacement("$_:$+", []);
    const preview = buildReplacementPreview(syntax, nativeShape, subject);

    expect(preview.matches[0]?.replacementPreview).toBe("ab:b");
    expect(preview.matches[0]?.contributions[0]?.sourceRange).toEqual({
      startUtf16: 0,
      endUtf16: 2,
    });
    expect(preview.matches[0]?.contributions[2]?.sourceRange).toEqual({
      startUtf16: 1,
      endUtf16: 2,
    });
  });

  it("maps each token to source and output ranges without executing code", () => {
    const subject = "a b";
    const syntax = parseEcmaScriptReplacement("[$<letter>]", captures);
    const preview = buildReplacementPreview(syntax, result(subject), subject);

    expect(preview.matches).toHaveLength(2);
    expect(preview.matches[0]).toMatchObject({
      matchNumber: 1,
      originalRange: { startUtf16: 0, endUtf16: 1 },
      originalPreview: "a",
      replacementPreview: "[a]",
      outputRange: { startUtf16: 0, endUtf16: 3 },
    });
    expect(preview.matches[1]).toMatchObject({
      matchNumber: 2,
      originalRange: { startUtf16: 2, endUtf16: 3 },
      replacementPreview: "[b]",
      outputRange: { startUtf16: 4, endUtf16: 7 },
    });
    const capture = preview.matches[0]?.contributions[1];
    expect(capture).toMatchObject({
      sourceRange: { startUtf16: 0, endUtf16: 1 },
      outputRange: { startUtf16: 1, endUtf16: 2 },
      valuePreview: "a",
      valueLengthUtf16: 1,
    });
  });

  it("reports unavailable output ranges when bounded output ends first", () => {
    const subject = "a";
    const syntax = parseEcmaScriptReplacement("$`$&$'", captures);
    const boundedResult = {
      ...result(subject, "$`$&$'"),
      output: "",
      outputBytes: 0,
      outputTruncated: true,
      truncated: true,
    };
    const preview = buildReplacementPreview(syntax, boundedResult, subject);

    expect(preview.matches[0]?.outputRange).toBeUndefined();
    expect(preview.matches[0]?.contributions[1]?.sourceRange).toEqual({
      startUtf16: 0,
      endUtf16: 1,
    });
    expect(preview.matches[0]?.contributions[1]?.outputRange).toBeUndefined();
  });

  it("uses complete capture values without indices but does not invent clipped lengths", () => {
    const syntax = parseEcmaScriptReplacement("$1", captures);
    const base = result("a", "$1", "(a)");
    const unavailableIndices: RegexReplacementResult = {
      ...base,
      execution: {
        ...base.execution,
        matches: [
          {
            ...base.execution.matches[0]!,
            captures: [
              {
                groupNumber: 1,
                value: "a",
                status: "unavailable",
              },
            ],
          },
        ],
      },
      output: "a",
      outputBytes: 1,
    };
    const completePreview = buildReplacementPreview(
      syntax,
      unavailableIndices,
      "a",
    );
    expect(completePreview.matches[0]?.contributions[0]).toMatchObject({
      valuePreview: "a",
      valueLengthUtf16: 1,
      valueLengthExact: true,
      outputRange: { startUtf16: 0, endUtf16: 1 },
    });

    const clippedCapture: RegexReplacementResult = {
      ...unavailableIndices,
      execution: {
        ...unavailableIndices.execution,
        matches: [
          {
            ...unavailableIndices.execution.matches[0]!,
            captures: [
              {
                groupNumber: 1,
                value: "a",
                status: "truncated",
              },
            ],
          },
        ],
      },
    };
    const clippedPreview = buildReplacementPreview(syntax, clippedCapture, "a");
    expect(clippedPreview.matches[0]?.contributions[0]).toMatchObject({
      valueLengthExact: false,
      valueTruncated: true,
    });
    expect(
      clippedPreview.matches[0]?.contributions[0]?.outputRange,
    ).toBeUndefined();
    expect(clippedPreview.matches[0]?.outputRange).toBeUndefined();
  });

  it("bounds match and contribution materialization explicitly", () => {
    const engineMatches = Array.from(
      { length: MAXIMUM_REPLACEMENT_PREVIEW_MATCHES + 1 },
      (_, index) => ({
        matchNumber: index + 1,
        value: "a",
        valueStatus: "complete" as const,
        range: { startUtf16: index, endUtf16: index + 1 },
        nativeRange: { start: index, end: index + 1, unit: "utf16" as const },
        captures: [],
      }),
    );
    const manyTokens: ReplacementSyntaxResult = {
      accepted: true,
      tokens: Array.from(
        { length: MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS + 1 },
        (_, index) => ({
          id: `token-${index}`,
          kind: "literal" as const,
          raw: "x",
          range: { startUtf16: index, endUtf16: index + 1 },
          explanation: "Insert x",
        }),
      ),
      diagnostics: [],
      totalDiagnostics: 0,
      diagnosticsTruncated: false,
    };
    const boundedResult: RegexReplacementResult = {
      execution: {
        ...result("a").execution,
        matches: engineMatches,
      },
      output: "x".repeat(MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS + 1),
      outputBytes: MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS + 1,
      outputTruncated: false,
      truncated: false,
    };
    const preview = buildReplacementPreview(
      manyTokens,
      boundedResult,
      "a".repeat(engineMatches.length),
    );

    expect(preview.matches).toHaveLength(
      Math.floor(
        MAXIMUM_REPLACEMENT_PREVIEW_TOKEN_EVALUATIONS /
          (MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS + 1),
      ),
    );
    expect(preview.renderedContributions).toBe(
      MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS,
    );
    expect(preview.truncated).toBe(true);
  });

  it("indexes captures once per visible match at the worst token-budget shape", () => {
    const capturesPerMatch = 1_000;
    const tokenCount = 16_384;
    let indexedCaptureReads = 0;
    const captureResults = Array.from(
      { length: capturesPerMatch },
      (_, index) => ({
        groupNumber: index + 1,
        groupName: index === capturesPerMatch - 1 ? "x" : `group-${index + 1}`,
        value: "a",
        status: "participated" as const,
        range: { startUtf16: 0, endUtf16: 1 },
      }),
    );
    const guardedCaptures = new Proxy(captureResults, {
      get(target, property, receiver) {
        if (property === "find") {
          throw new Error("capture lookup must not scan once per token");
        }
        if (typeof property === "string" && /^\d+$/u.test(property)) {
          indexedCaptureReads += 1;
        }
        return Reflect.get(target, property, receiver);
      },
    });
    const tokens: ReplacementSyntaxResult["tokens"] = Array.from(
      { length: tokenCount },
      (_, index) => ({
        id: `named-${index}`,
        kind: "named-capture" as const,
        raw: "$<x>",
        range: {
          startUtf16: index * 4,
          endUtf16: index * 4 + 4,
        },
        explanation: "Insert named capture x",
        captureName: "x",
      }),
    );
    const visibleMatchCount = Math.floor(
      MAXIMUM_REPLACEMENT_PREVIEW_TOKEN_EVALUATIONS / tokenCount,
    );
    const execution = result("a").execution;
    const worstCase: RegexReplacementResult = {
      execution: {
        ...execution,
        matches: Array.from({ length: visibleMatchCount }, (_, index) => ({
          ...execution.matches[0]!,
          matchNumber: index + 1,
          captures: guardedCaptures,
        })),
      },
      output: "a".repeat(tokenCount * visibleMatchCount),
      outputBytes: tokenCount * visibleMatchCount,
      outputTruncated: false,
      truncated: false,
    };
    const preview = buildReplacementPreview(
      {
        accepted: true,
        tokens,
        diagnostics: [],
        totalDiagnostics: 0,
        diagnosticsTruncated: false,
      },
      worstCase,
      "a",
    );

    expect(preview.matches).toHaveLength(visibleMatchCount);
    expect(indexedCaptureReads).toBe(capturesPerMatch * visibleMatchCount);
  });

  it("prefers a participating duplicate name for (?<x>a)|(?<x>b) on b", () => {
    const duplicateCaptures: readonly CaptureDefinition[] = [
      {
        number: 1,
        name: "x",
        range: { startUtf16: 0, endUtf16: 7 },
        repeated: false,
      },
      {
        number: 2,
        name: "x",
        range: { startUtf16: 8, endUtf16: 15 },
        repeated: false,
      },
    ];
    const syntax = parseEcmaScriptReplacement("$<x>", duplicateCaptures);
    const base = result("b");
    const duplicateResult: RegexReplacementResult = {
      ...base,
      execution: {
        ...base.execution,
        matches: [
          {
            ...base.execution.matches[0]!,
            value: "b",
            captures: [
              {
                groupNumber: 1,
                groupName: "x",
                status: "did-not-participate",
              },
              {
                groupNumber: 2,
                groupName: "x",
                value: "b",
                status: "participated",
                range: { startUtf16: 0, endUtf16: 1 },
              },
            ],
          },
        ],
      },
      output: "b",
      outputBytes: 1,
      outputTruncated: false,
      truncated: false,
    };

    const preview = buildReplacementPreview(syntax, duplicateResult, "b");

    expect(preview.matches[0]?.replacementPreview).toBe("b");
    expect(preview.matches[0]?.contributions[0]).toMatchObject({
      valuePreview: "b",
      sourceRange: { startUtf16: 0, endUtf16: 1 },
    });
  });
});
