import type { RegexDiagnostic } from "../../../model/diagnostics";
import type {
  Pcre2TraceEvent,
  Pcre2TraceRequest,
  Pcre2TraceResult,
} from "../../../model/trace";
import {
  buildUtf8OffsetMap,
  utf8ByteToUtf16,
} from "../../offsets/utf8-to-utf16";
import { DEFAULT_REGEX_LIMITS } from "../../request-limits";
import {
  normalizedPcre2Flags,
  PCRE2_BRIDGE_ABI_VERSION,
  PCRE2_ENGINE_VERSION,
  pcre2ApplicationFlags,
  pcre2EngineInfo,
  pcre2Limits,
} from "./Pcre2EngineAdapter";
import type { Pcre2EmscriptenModule } from "./pcre2-module";

const TEXT_ENCODER = new TextEncoder();
const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
const TRACE_LIMIT_BYTES = 20;
const TRACE_EVENT_BYTES = 32;
const TRACE_RESULT_BYTES = 52;
const ERROR_MESSAGE_BYTES = 256;
const FLAG_ORDER = "gimsxUJ";
const NO_COMPLETE_EVENT = 0xffff_ffff;

interface NativeTraceResult {
  readonly status: number;
  readonly errorPhase: number;
  readonly errorOffset: number;
  readonly nativeMatchStatus: number;
  readonly eventCount: number;
  readonly totalEventCount: number;
  readonly markBytesLength: number;
  readonly traceBytesLength: number;
  readonly eventsTruncated: boolean;
  readonly marksTruncated: boolean;
  readonly lastCompleteEvent: number;
  readonly matched: boolean;
}

class TraceAllocations {
  readonly #module: Pcre2EmscriptenModule;
  readonly #pointers: number[] = [];

  constructor(module: Pcre2EmscriptenModule) {
    this.#module = module;
  }

  allocate(bytes: number): number {
    const pointer = this.#module._malloc(Math.max(1, bytes));
    if (!pointer) {
      throw new Error(
        `PCRE2 trace could not allocate ${bytes.toLocaleString()} bytes.`,
      );
    }
    this.#pointers.push(pointer);
    return pointer;
  }

  encoded(value: Uint8Array): number {
    const pointer = this.allocate(value.byteLength);
    if (value.byteLength > 0) this.#module.HEAPU8.set(value, pointer);
    return pointer;
  }

  free(): void {
    for (let index = this.#pointers.length - 1; index >= 0; index -= 1) {
      this.#module._free(this.#pointers[index] ?? 0);
    }
    this.#pointers.length = 0;
  }
}

function traceFailure(
  request: Pcre2TraceRequest,
  message: string,
  code: string,
  start: number,
  range?: { readonly startUtf16: number; readonly endUtf16: number },
): Pcre2TraceResult {
  const flags = normalizedPcre2Flags(request.flags, false);
  return {
    accepted: false,
    engine: pcre2EngineInfo(),
    flags,
    events: [],
    diagnostics: [
      {
        id: `pcre2-trace-${code}`,
        source: "execution-engine",
        severity: "error",
        code,
        message,
        ...(range ? { range } : {}),
        flavour: "pcre2",
        provenance: "reported",
      },
    ],
    elapsedMs: performance.now() - start,
    truncated: false,
    marksTruncated: false,
    totalEventCount: 0,
    traceBytes: 0,
    nativeMatchStatus: 0,
    matched: false,
  };
}

function validateRequest(
  request: Pcre2TraceRequest,
  patternBytes: number,
  subjectBytes: number,
): void {
  if (request.flavour !== "pcre2") {
    throw new Error(`PCRE2 trace cannot execute ${request.flavour}.`);
  }
  if (
    new Set(request.flags).size !== request.flags.length ||
    request.flags.some((flag) => !FLAG_ORDER.includes(flag))
  ) {
    throw new RangeError("The trace request contains unsupported PCRE2 flags.");
  }
  if (patternBytes > 1_048_576) {
    throw new RangeError("PCRE2 trace patterns are limited to 1 MiB of UTF-8.");
  }
  if (subjectBytes > DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes) {
    throw new RangeError(
      "PCRE2 trace subjects are limited to 16 MiB of UTF-8.",
    );
  }
  if (
    !Number.isSafeInteger(request.maximumTraceEvents) ||
    request.maximumTraceEvents < 1 ||
    request.maximumTraceEvents > DEFAULT_REGEX_LIMITS.maximumTraceEvents
  ) {
    throw new RangeError(
      `PCRE2 traces retain from 1 to ${DEFAULT_REGEX_LIMITS.maximumTraceEvents.toLocaleString()} events.`,
    );
  }
  if (
    !Number.isSafeInteger(request.maximumTraceBytes) ||
    request.maximumTraceBytes < TRACE_EVENT_BYTES ||
    request.maximumTraceBytes > DEFAULT_REGEX_LIMITS.maximumTraceBytes
  ) {
    throw new RangeError(
      `PCRE2 traces retain from ${TRACE_EVENT_BYTES} to ${DEFAULT_REGEX_LIMITS.maximumTraceBytes.toLocaleString()} serialized bytes.`,
    );
  }
}

function readNativeResult(
  module: Pcre2EmscriptenModule,
  pointer: number,
): NativeTraceResult {
  const view = new DataView(module.HEAPU8.buffer, pointer, TRACE_RESULT_BYTES);
  return {
    status: view.getInt32(0, true),
    errorPhase: view.getUint32(4, true),
    errorOffset: view.getUint32(8, true),
    nativeMatchStatus: view.getInt32(12, true),
    eventCount: view.getUint32(20, true),
    totalEventCount: view.getUint32(24, true),
    markBytesLength: view.getUint32(28, true),
    traceBytesLength: view.getUint32(32, true),
    eventsTruncated: view.getUint32(36, true) !== 0,
    marksTruncated: view.getUint32(40, true) !== 0,
    lastCompleteEvent: view.getUint32(44, true),
    matched: view.getUint32(48, true) !== 0,
  };
}

function movement(
  current: number,
  previous: number | undefined,
): Pcre2TraceEvent["movement"] {
  if (previous === undefined) {
    return {
      provenance: "derived",
      classification: "first-event",
      subjectDeltaBytes: 0,
      explanation: "First retained reported callout event.",
    };
  }
  const delta = current - previous;
  if (delta > 0) {
    return {
      provenance: "derived",
      classification: "forward",
      subjectDeltaBytes: delta,
      explanation:
        "Derived only: the reported subject position moved forward between adjacent retained callouts.",
    };
  }
  if (delta === 0) {
    return {
      provenance: "derived",
      classification: "same-position",
      subjectDeltaBytes: 0,
      explanation:
        "Derived only: adjacent retained callouts report the same subject position.",
    };
  }
  return {
    provenance: "derived",
    classification: "apparent-backtrack",
    subjectDeltaBytes: delta,
    explanation:
      "Derived only: the reported subject position moved backward between adjacent retained callouts; this is not a complete internal engine trace.",
  };
}

function materializeEvents(
  module: Pcre2EmscriptenModule,
  eventsPointer: number,
  marksPointer: number,
  native: NativeTraceResult,
  pattern: string,
  subject: string,
): readonly Pcre2TraceEvent[] {
  const patternMap = buildUtf8OffsetMap(pattern);
  const subjectMap = buildUtf8OffsetMap(subject);
  const view = new DataView(module.HEAPU8.buffer);
  const events: Pcre2TraceEvent[] = [];
  let previousSubjectPosition: number | undefined;
  for (let index = 0; index < native.eventCount; index += 1) {
    const pointer = eventsPointer + index * TRACE_EVENT_BYTES;
    const calloutNumber = view.getUint32(pointer, true);
    const patternPositionByte = view.getUint32(pointer + 4, true);
    const nextItemLengthByte = view.getUint32(pointer + 8, true);
    const subjectPositionByte = view.getUint32(pointer + 12, true);
    const captureTop = view.getUint32(pointer + 16, true);
    const captureLast = view.getUint32(pointer + 20, true);
    const markOffset = view.getUint32(pointer + 24, true);
    const markLength = view.getUint32(pointer + 28, true);
    const nextEndByte = patternPositionByte + nextItemLengthByte;
    if (
      nextEndByte > patternMap.byteLength ||
      subjectPositionByte > subjectMap.byteLength ||
      markOffset + markLength > native.markBytesLength ||
      captureTop > DEFAULT_REGEX_LIMITS.maximumCaptureGroups + 1 ||
      captureLast > DEFAULT_REGEX_LIMITS.maximumCaptureGroups
    ) {
      throw new Error("PCRE2 returned an invalid automatic-callout event.");
    }
    const patternPositionUtf16 = utf8ByteToUtf16(
      patternMap,
      patternPositionByte,
    );
    const nextEndUtf16 = utf8ByteToUtf16(patternMap, nextEndByte);
    const subjectPositionUtf16 = utf8ByteToUtf16(
      subjectMap,
      subjectPositionByte,
    );
    const mark =
      markLength === 0
        ? undefined
        : TEXT_DECODER.decode(
            module.HEAPU8.slice(
              marksPointer + markOffset,
              marksPointer + markOffset + markLength,
            ),
          );
    events.push({
      eventNumber: index + 1,
      reported: {
        provenance: "reported",
        calloutNumber,
        patternPosition: {
          utf16: patternPositionUtf16,
          nativeByte: patternPositionByte,
        },
        nextPatternItem: {
          startUtf16: patternPositionUtf16,
          endUtf16: nextEndUtf16,
          startNativeByte: patternPositionByte,
          endNativeByte: nextEndByte,
        },
        subjectPosition: {
          utf16: subjectPositionUtf16,
          nativeByte: subjectPositionByte,
        },
        captureTop,
        captureLast,
        ...(mark ? { mark } : {}),
      },
      movement: movement(subjectPositionByte, previousSubjectPosition),
    });
    previousSubjectPosition = subjectPositionByte;
  }
  return events;
}

function nativeErrorMessage(
  module: Pcre2EmscriptenModule,
  status: number,
): string {
  const pointer = module._malloc(ERROR_MESSAGE_BYTES);
  if (!pointer) return `native error ${status}`;
  try {
    module.HEAPU8.fill(0, pointer, pointer + ERROR_MESSAGE_BYTES);
    const length = module._regex_pcre2_error_message(
      status,
      pointer,
      ERROR_MESSAGE_BYTES,
    );
    return length >= 0
      ? TEXT_DECODER.decode(module.HEAPU8.slice(pointer, pointer + length))
      : `native error ${status}`;
  } finally {
    module._free(pointer);
  }
}

export class Pcre2TraceAdapter {
  readonly #module: Pcre2EmscriptenModule;
  readonly #runtimeVersion?: string;

  constructor(module: Pcre2EmscriptenModule, runtimeVersion?: string) {
    this.#module = module;
    this.#runtimeVersion = runtimeVersion;
  }

  async load(): Promise<void> {
    if (
      this.#module._regex_pcre2_bridge_abi_version() !==
        PCRE2_BRIDGE_ABI_VERSION ||
      this.#module._regex_pcre2_config_flags() !== 1 ||
      this.#module.UTF8ToString(this.#module._regex_pcre2_version()) !==
        PCRE2_ENGINE_VERSION ||
      this.#module._regex_pcre2_self_test() !== 0
    ) {
      throw new Error(
        "The bundled PCRE2 trace engine failed its identity or bridge self-test.",
      );
    }
  }

  async trace(request: Pcre2TraceRequest): Promise<Pcre2TraceResult> {
    const start = performance.now();
    const patternMap = buildUtf8OffsetMap(request.pattern);
    const subjectMap = buildUtf8OffsetMap(request.subject);
    if (patternMap.containsLoneSurrogate || subjectMap.containsLoneSurrogate) {
      return traceFailure(
        request,
        "PCRE2 trace refuses an unpaired UTF-16 surrogate because browser UTF-8 encoding would replace it.",
        "invalid-unicode-input",
        start,
      );
    }
    const pattern = TEXT_ENCODER.encode(request.pattern);
    const subject = TEXT_ENCODER.encode(request.subject);
    validateRequest(request, pattern.byteLength, subject.byteLength);
    const limits = pcre2Limits(request.options);
    const flags = normalizedPcre2Flags(request.flags, false);
    const allocations = new TraceAllocations(this.#module);
    try {
      const patternPointer = allocations.encoded(pattern);
      const subjectPointer = allocations.encoded(subject);
      const limitsPointer = allocations.allocate(TRACE_LIMIT_BYTES);
      const eventsPointer = allocations.allocate(
        request.maximumTraceEvents * TRACE_EVENT_BYTES,
      );
      const marksPointer = allocations.allocate(request.maximumTraceBytes);
      const resultPointer = allocations.allocate(TRACE_RESULT_BYTES);
      const limitsView = new DataView(
        this.#module.HEAPU8.buffer,
        limitsPointer,
        TRACE_LIMIT_BYTES,
      );
      limitsView.setUint32(0, request.maximumTraceEvents, true);
      limitsView.setUint32(4, request.maximumTraceBytes, true);
      limitsView.setUint32(8, limits.matchLimit, true);
      limitsView.setUint32(12, limits.depthLimit, true);
      limitsView.setUint32(16, limits.heapLimitKib, true);
      this.#module.HEAPU8.fill(
        0,
        resultPointer,
        resultPointer + TRACE_RESULT_BYTES,
      );
      this.#module._regex_pcre2_trace(
        patternPointer,
        pattern.byteLength,
        subjectPointer,
        subject.byteLength,
        pcre2ApplicationFlags(flags),
        limitsPointer,
        eventsPointer,
        request.maximumTraceEvents,
        marksPointer,
        request.maximumTraceBytes,
        resultPointer,
      );
      const native = readNativeResult(this.#module, resultPointer);
      if (
        native.eventCount > request.maximumTraceEvents ||
        native.totalEventCount < native.eventCount ||
        native.markBytesLength > request.maximumTraceBytes ||
        native.traceBytesLength !==
          native.eventCount * TRACE_EVENT_BYTES + native.markBytesLength ||
        native.traceBytesLength > request.maximumTraceBytes ||
        (native.eventCount === 0 &&
          native.lastCompleteEvent !== NO_COMPLETE_EVENT) ||
        (native.eventCount > 0 &&
          native.lastCompleteEvent !== native.eventCount - 1)
      ) {
        throw new Error("PCRE2 returned data outside the trace ABI contract.");
      }
      if (native.status !== 0) {
        let range:
          | { readonly startUtf16: number; readonly endUtf16: number }
          | undefined;
        if (native.errorPhase === 2) {
          try {
            const offset = utf8ByteToUtf16(patternMap, native.errorOffset);
            range = { startUtf16: offset, endUtf16: offset };
          } catch {
            range = undefined;
          }
        }
        return traceFailure(
          request,
          `PCRE2 trace ${
            native.errorPhase === 2 ? "compile" : "match"
          } error: ${nativeErrorMessage(this.#module, native.status)}.`,
          native.errorPhase === 2
            ? "compile-error"
            : native.status === -47
              ? "match-limit"
              : "trace-error",
          start,
          range,
        );
      }

      const events = materializeEvents(
        this.#module,
        eventsPointer,
        marksPointer,
        native,
        request.pattern,
        request.subject,
      );
      const diagnostics: RegexDiagnostic[] = [];
      if (request.flags.includes("g")) {
        diagnostics.push({
          id: "pcre2-trace-single-invocation",
          source: "execution-engine",
          severity: "information",
          code: "trace-single-invocation",
          message:
            "The application-level g flag is not applied to tracing. This stream reports one exact pcre2_match() invocation.",
          flavour: "pcre2",
          provenance: "derived",
        });
      }
      if (native.eventsTruncated) {
        diagnostics.push({
          id: "pcre2-trace-events-truncated",
          source: "execution-engine",
          severity: "warning",
          code: "trace-limit",
          message: `Trace collection stopped after ${native.eventCount.toLocaleString()} complete events at the configured event or serialized-byte cap.`,
          flavour: "pcre2",
          provenance: "reported",
        });
      }
      if (native.marksTruncated) {
        diagnostics.push({
          id: "pcre2-trace-marks-truncated",
          source: "execution-engine",
          severity: "warning",
          code: "trace-mark-limit",
          message:
            "At least one reported PCRE2 mark was clipped at the per-event mark byte limit.",
          flavour: "pcre2",
          provenance: "reported",
        });
      }
      if (events.length === 0 && !native.eventsTruncated) {
        diagnostics.push({
          id: "pcre2-trace-no-events",
          source: "execution-engine",
          severity: "information",
          code: "trace-empty",
          message:
            "PCRE2 reported no automatic callouts. Start optimizations can conclude some matches before the instrumented matcher runs.",
          flavour: "pcre2",
          provenance: "reported",
        });
      }
      return {
        accepted: true,
        engine: pcre2EngineInfo(this.#runtimeVersion),
        flags,
        events,
        diagnostics,
        elapsedMs: performance.now() - start,
        truncated: native.eventsTruncated,
        marksTruncated: native.marksTruncated,
        totalEventCount: native.totalEventCount,
        traceBytes: native.traceBytesLength,
        nativeMatchStatus: native.nativeMatchStatus,
        matched: native.matched,
        ...(native.lastCompleteEvent === NO_COMPLETE_EVENT
          ? {}
          : { lastCompleteEvent: native.lastCompleteEvent + 1 }),
      };
    } finally {
      allocations.free();
    }
  }
}
