import type { RegexEngineAdapter } from "../../RegexEngineAdapter";
import { DEFAULT_REGEX_LIMITS, utf8ByteLength } from "../../request-limits";
import type { RegexDiagnostic } from "../../../model/diagnostics";
import type { RegexEngineInfo } from "../../../model/flavour";
import type {
  CaptureResult,
  RegexExecutionFlags,
  RegexExecutionRequest,
  RegexExecutionResult,
  RegexMatchResult,
  RegexReplacementRequest,
  RegexReplacementResult,
} from "../../../model/match";
import { APPLICATION_VERSION } from "../../../../version";
import PYTHON_BRIDGE_SOURCE from "../../../../../engines/python/bridge.py?raw";
import {
  decodeBase64OutputEnvelope,
  maximumBase64JsonUtf16,
} from "../portable/base64-output";
import type { PyodideRuntime, PythonCallableProxy } from "./python-module";

export const PYTHON_BRIDGE_ABI_VERSION = 1;
export const PYTHON_ENGINE_VERSION = "CPython 3.14.2 re";
export const PYODIDE_ENGINE_VERSION = "314.0.3";
export const PYTHON_FLAVOUR_VERSION = "CPython 3.14 via Pyodide 314.0.3";
const PYTHON_COMPATIBLE_FLAVOUR_VERSIONS = new Set([
  PYTHON_FLAVOUR_VERSION,
  "Python 3.14",
  "Pyodide 314.0.3",
]);

const FLAG_ORDER = "gaimsx";
const CAPTURE_VALUE_PREVIEW_UTF16 = 64 * 1024;
const TOTAL_VALUE_PREVIEW_UTF16 = 8 * 1024 * 1024;
const EXECUTION_RESULT_JSON_UTF16_LIMIT = 32 * 1024 * 1024;

interface NativeError {
  readonly abi: number;
  readonly ok: false;
  readonly phase: "bridge" | "compile" | "match" | "replacement";
  readonly code: string;
  readonly message: string;
  readonly position?: number;
}

interface NativeIdentity {
  readonly abi: number;
  readonly ok: true;
  readonly identity: {
    readonly implementation: string;
    readonly pythonVersion: string;
    readonly reModule: string;
  };
}

type NativeSpan = readonly [number, number];

interface NativeMatch {
  readonly span: NativeSpan;
  readonly captures: readonly (NativeSpan | null)[];
}

interface NativeExecution {
  readonly abi: number;
  readonly ok: true;
  readonly groupCount: number;
  readonly groupNames: readonly (readonly [number, string])[];
  readonly matches: readonly NativeMatch[];
  readonly resultsTruncated: boolean;
  readonly output?: string;
  readonly outputBytes?: number;
  readonly outputTruncated?: boolean;
}

class ValuePreviewBudget {
  #remaining = TOTAL_VALUE_PREVIEW_UTF16;
  truncated = false;

  take(value: string): { readonly value: string; readonly truncated: boolean } {
    const maximum = Math.min(CAPTURE_VALUE_PREVIEW_UTF16, this.#remaining);
    const preview = value.slice(0, maximum);
    this.#remaining -= preview.length;
    const truncated = preview.length !== value.length;
    this.truncated ||= truncated;
    return { value: preview, truncated };
  }
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function codePointLength(value: string): number {
  let length = 0;
  for (let index = 0; index < value.length; index += 1) {
    const first = value.charCodeAt(index);
    if (first >= 0xd800 && first <= 0xdbff && index + 1 < value.length) {
      const second = value.charCodeAt(index + 1);
      if (second >= 0xdc00 && second <= 0xdfff) index += 1;
    }
    length += 1;
  }
  return length;
}

function codePointOffsetToUtf16(value: string, target: number): number {
  if (!Number.isSafeInteger(target) || target < 0) {
    throw new RangeError(`Invalid code-point offset ${target}.`);
  }
  let codePointOffset = 0;
  let utf16Offset = 0;
  if (target === 0) return 0;
  for (const character of value) {
    codePointOffset += 1;
    utf16Offset += character.length;
    if (codePointOffset === target) return utf16Offset;
  }
  throw new RangeError(`Code-point offset ${target} is outside the input.`);
}

function selectedCodePointOffsetsToUtf16(
  value: string,
  selected: ReadonlySet<number>,
): ReadonlyMap<number, number> {
  const result = new Map<number, number>();
  let codePointOffset = 0;
  let utf16Offset = 0;
  if (selected.has(0)) result.set(0, 0);
  for (const character of value) {
    codePointOffset += 1;
    utf16Offset += character.length;
    if (selected.has(codePointOffset)) {
      result.set(codePointOffset, utf16Offset);
    }
  }
  if (result.size !== selected.size) {
    throw new Error("A Python native range is outside the subject.");
  }
  return result;
}

function parseBridgeJson(value: unknown, maximumUtf16: number): unknown {
  if (typeof value !== "string") {
    throw new Error("The Python bridge returned a non-string result.");
  }
  if (value.length > maximumUtf16) {
    throw new Error("The Python bridge result exceeds its bounded contract.");
  }
  try {
    return JSON.parse(value) as unknown;
  } catch {
    throw new Error("The Python bridge returned invalid JSON.");
  }
}

function parseNativeError(value: unknown): NativeError | undefined {
  if (!isRecord(value) || value.ok !== false) return undefined;
  if (
    value.abi !== PYTHON_BRIDGE_ABI_VERSION ||
    !["bridge", "compile", "match", "replacement"].includes(
      String(value.phase),
    ) ||
    typeof value.code !== "string" ||
    typeof value.message !== "string" ||
    (value.position !== undefined &&
      (!Number.isSafeInteger(value.position) || Number(value.position) < 0))
  ) {
    throw new Error("The Python bridge returned an invalid error record.");
  }
  return value as unknown as NativeError;
}

function requireIdentity(value: unknown): NativeIdentity {
  if (
    !isRecord(value) ||
    value.abi !== PYTHON_BRIDGE_ABI_VERSION ||
    value.ok !== true ||
    !isRecord(value.identity) ||
    typeof value.identity.implementation !== "string" ||
    typeof value.identity.pythonVersion !== "string" ||
    typeof value.identity.reModule !== "string"
  ) {
    throw new Error("The Python bridge returned an invalid identity record.");
  }
  return value as unknown as NativeIdentity;
}

function isNativeSpan(
  value: unknown,
  maximumCodePoint: number,
): value is NativeSpan {
  return (
    Array.isArray(value) &&
    value.length === 2 &&
    Number.isSafeInteger(value[0]) &&
    Number.isSafeInteger(value[1]) &&
    Number(value[0]) >= 0 &&
    Number(value[0]) <= Number(value[1]) &&
    Number(value[1]) <= maximumCodePoint
  );
}

function requireExecution(
  value: unknown,
  request: RegexExecutionRequest,
  subjectCodePointLength: number,
  replacementRequest?: RegexReplacementRequest,
): NativeExecution {
  if (
    !isRecord(value) ||
    value.abi !== PYTHON_BRIDGE_ABI_VERSION ||
    value.ok !== true ||
    !Number.isSafeInteger(value.groupCount) ||
    Number(value.groupCount) < 0 ||
    Number(value.groupCount) > DEFAULT_REGEX_LIMITS.maximumCaptureGroups ||
    !Array.isArray(value.groupNames) ||
    !Array.isArray(value.matches) ||
    typeof value.resultsTruncated !== "boolean" ||
    value.matches.length > request.maximumMatches
  ) {
    throw new Error("The Python bridge returned an invalid result record.");
  }

  const groupCount = Number(value.groupCount);
  const names = new Set<number>();
  for (const entry of value.groupNames) {
    if (
      !Array.isArray(entry) ||
      entry.length !== 2 ||
      !Number.isSafeInteger(entry[0]) ||
      Number(entry[0]) < 1 ||
      Number(entry[0]) > groupCount ||
      names.has(Number(entry[0])) ||
      typeof entry[1] !== "string"
    ) {
      throw new Error("The Python bridge returned invalid capture names.");
    }
    names.add(Number(entry[0]));
  }

  let captureRows = 0;
  for (const entry of value.matches) {
    if (
      !isRecord(entry) ||
      !isNativeSpan(entry.span, subjectCodePointLength) ||
      !Array.isArray(entry.captures) ||
      entry.captures.length !== groupCount
    ) {
      throw new Error("The Python bridge returned an invalid match record.");
    }
    captureRows += Math.max(1, groupCount);
    if (captureRows > request.maximumCaptureRows) {
      throw new Error("The Python bridge exceeded the capture-row limit.");
    }
    for (const capture of entry.captures) {
      if (capture !== null && !isNativeSpan(capture, subjectCodePointLength)) {
        throw new Error("The Python bridge returned an invalid capture range.");
      }
    }
  }

  if (replacementRequest) {
    if (
      typeof value.output !== "string" ||
      !Number.isSafeInteger(value.outputBytes) ||
      Number(value.outputBytes) < 0 ||
      Number(value.outputBytes) > replacementRequest.maximumOutputBytes ||
      utf8ByteLength(value.output) !== value.outputBytes ||
      typeof value.outputTruncated !== "boolean"
    ) {
      throw new Error(
        "The Python bridge returned invalid bounded replacement output.",
      );
    }
  } else if (
    value.output !== undefined ||
    value.outputBytes !== undefined ||
    value.outputTruncated !== undefined
  ) {
    throw new Error(
      "The Python bridge returned replacement data for an execution request.",
    );
  }
  return value as unknown as NativeExecution;
}

function normalizedPythonFlags(
  userFlags: readonly string[],
  scanAll: boolean,
): RegexExecutionFlags {
  const selected = new Set(userFlags);
  const internallyAddedGlobalFlag = scanAll && !selected.has("g");
  if (internallyAddedGlobalFlag) selected.add("g");
  return {
    userFlags: [...FLAG_ORDER]
      .filter((flag) => userFlags.includes(flag))
      .join(""),
    effectiveFlags: [...FLAG_ORDER]
      .filter((flag) => selected.has(flag))
      .join(""),
    internallyAddedIndicesFlag: false,
    internallyAddedGlobalFlag,
  };
}

function validateRequest(
  request: RegexExecutionRequest,
  replacementRequest?: RegexReplacementRequest,
): void {
  if (request.flavour !== "python") {
    throw new Error(`Python adapter cannot execute ${request.flavour}.`);
  }
  if (
    request.flavourVersion !== undefined &&
    !PYTHON_COMPATIBLE_FLAVOUR_VERSIONS.has(request.flavourVersion)
  ) {
    throw new RangeError(
      `Python re version ${JSON.stringify(request.flavourVersion)} is unsupported by this engine.`,
    );
  }
  if (
    new Set(request.flags).size !== request.flags.length ||
    request.flags.some(
      (flag) => flag.length !== 1 || !FLAG_ORDER.includes(flag),
    )
  ) {
    throw new RangeError("The request contains unsupported Python re flags.");
  }
  if (request.pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
    throw new RangeError(
      `Python re patterns are limited to ${DEFAULT_REGEX_LIMITS.patternHardLengthUtf16.toLocaleString()} UTF-16 units.`,
    );
  }
  if (
    utf8ByteLength(request.subject) >
    DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
  ) {
    throw new RangeError("Python re subjects are limited to 16 MiB of UTF-8.");
  }
  if (
    !Number.isSafeInteger(request.maximumMatches) ||
    request.maximumMatches < 1 ||
    request.maximumMatches > DEFAULT_REGEX_LIMITS.maximumMatches ||
    !Number.isSafeInteger(request.maximumCaptureRows) ||
    request.maximumCaptureRows < 1 ||
    request.maximumCaptureRows > DEFAULT_REGEX_LIMITS.maximumCaptureRows
  ) {
    throw new RangeError(
      "Python re result limits are outside the bounded contract.",
    );
  }
  if (request.options && Object.keys(request.options).length > 0) {
    throw new RangeError(
      "The Python re engine does not accept engine options.",
    );
  }
  if (
    replacementRequest &&
    (replacementRequest.replacement.length >
      DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16 ||
      !Number.isSafeInteger(replacementRequest.maximumOutputBytes) ||
      replacementRequest.maximumOutputBytes < 1 ||
      replacementRequest.maximumOutputBytes >
        DEFAULT_REGEX_LIMITS.maximumReplacementOutputBytes)
  ) {
    throw new RangeError(
      "Python replacement or output limits are outside the bounded contract.",
    );
  }
}

export function pythonEngineInfo(runtimeIdentity?: string): RegexEngineInfo {
  return {
    flavour: "python",
    adapterVersion: APPLICATION_VERSION,
    engineName: "CPython re via Pyodide",
    engineVersion: PYTHON_ENGINE_VERSION,
    runtimeVersion: [
      `Pyodide ${PYODIDE_ENGINE_VERSION}`,
      "WebAssembly",
      runtimeIdentity,
    ]
      .filter(Boolean)
      .join(" · "),
    offsetUnit: "code-point",
    capabilities: {
      compilation: true,
      matching: true,
      replacement: true,
      namedCaptures: true,
      captureHistory: false,
      actualTrace: false,
      benchmark: false,
    },
  };
}

function diagnosticForError(
  error: NativeError,
  pattern: string,
): RegexDiagnostic {
  let range:
    { readonly startUtf16: number; readonly endUtf16: number } | undefined;
  if (
    error.phase === "compile" &&
    error.position !== undefined &&
    error.position <= codePointLength(pattern)
  ) {
    const offset = codePointOffsetToUtf16(pattern, error.position);
    range = { startUtf16: offset, endUtf16: offset };
  }
  return {
    id: `python-${error.phase}-${error.code}`,
    source: "execution-engine",
    severity: "error",
    code: error.code,
    message: `Python re ${error.phase} error: ${error.message}`,
    ...(range ? { range } : {}),
    flavour: "python",
    provenance: "reported",
  };
}

function materializeMatches(
  native: NativeExecution,
  request: RegexExecutionRequest,
): {
  readonly matches: readonly RegexMatchResult[];
  readonly valuesTruncated: boolean;
} {
  const names = new Map(native.groupNames);
  const previews = new ValuePreviewBudget();
  const selectedOffsets = new Set<number>();
  for (const match of native.matches) {
    selectedOffsets.add(match.span[0]);
    selectedOffsets.add(match.span[1]);
    for (const capture of match.captures) {
      if (capture !== null) {
        selectedOffsets.add(capture[0]);
        selectedOffsets.add(capture[1]);
      }
    }
  }
  const editorOffsets = selectedCodePointOffsetsToUtf16(
    request.subject,
    selectedOffsets,
  );
  const editorOffset = (nativeOffset: number): number => {
    const value = editorOffsets.get(nativeOffset);
    if (value === undefined) {
      throw new Error("A Python native offset was not normalized.");
    }
    return value;
  };
  const matches = native.matches.map((match, index): RegexMatchResult => {
    const startUtf16 = editorOffset(match.span[0]);
    const endUtf16 = editorOffset(match.span[1]);
    const value = request.subject.slice(startUtf16, endUtf16);
    const preview = previews.take(value);
    const captures = match.captures.map((span, captureIndex): CaptureResult => {
      const groupNumber = captureIndex + 1;
      const groupName = names.get(groupNumber);
      if (span === null) {
        return {
          groupNumber,
          ...(groupName ? { groupName } : {}),
          status: "did-not-participate",
        };
      }
      const captureStartUtf16 = editorOffset(span[0]);
      const captureEndUtf16 = editorOffset(span[1]);
      const captureValue = request.subject.slice(
        captureStartUtf16,
        captureEndUtf16,
      );
      const capturePreview = previews.take(captureValue);
      return {
        groupNumber,
        ...(groupName ? { groupName } : {}),
        value: capturePreview.value,
        status: capturePreview.truncated
          ? "truncated"
          : captureValue.length === 0
            ? "matched-empty"
            : "participated",
        range: {
          startUtf16: captureStartUtf16,
          endUtf16: captureEndUtf16,
        },
        nativeRange: {
          start: span[0],
          end: span[1],
          unit: "code-point",
        },
      };
    });
    return {
      matchNumber: index + 1,
      value: preview.value,
      valueStatus: preview.truncated ? "truncated" : "complete",
      range: { startUtf16, endUtf16 },
      nativeRange: {
        start: match.span[0],
        end: match.span[1],
        unit: "code-point",
      },
      captures,
    };
  });
  return { matches, valuesTruncated: previews.truncated };
}

function resultDiagnostics(
  request: RegexExecutionRequest,
  native: NativeExecution,
  valuesTruncated: boolean,
  replacementRequest?: RegexReplacementRequest,
): readonly RegexDiagnostic[] {
  const diagnostics: RegexDiagnostic[] = [];
  if (native.resultsTruncated) {
    diagnostics.push({
      id: "python-results-truncated",
      source: "execution-engine",
      severity: "warning",
      code: "result-limit",
      message: `Results reached the configured limit (${request.maximumMatches.toLocaleString()} matches or ${request.maximumCaptureRows.toLocaleString()} capture rows).`,
      flavour: "python",
      provenance: "derived",
    });
  }
  if (valuesTruncated) {
    diagnostics.push({
      id: "python-value-previews-truncated",
      source: "execution-engine",
      severity: "warning",
      code: "result-value-preview-limit",
      message:
        "Displayed Python re result values were clipped to bounded previews; exact code-point and UTF-16 ranges are retained.",
      flavour: "python",
      provenance: "derived",
    });
  }
  if (replacementRequest && native.outputTruncated) {
    diagnostics.push({
      id: "python-output-truncated",
      source: "execution-engine",
      severity: "warning",
      code: "replacement-output-limit",
      message: `Replacement preview was truncated at ${replacementRequest.maximumOutputBytes.toLocaleString()} UTF-8 bytes.`,
      flavour: "python",
      provenance: "derived",
    });
  }
  return diagnostics;
}

function failedExecution(
  info: RegexEngineInfo,
  flags: RegexExecutionFlags,
  start: number,
  diagnostic: RegexDiagnostic,
): RegexExecutionResult {
  return {
    accepted: false,
    engine: info,
    flags,
    matches: [],
    diagnostics: [diagnostic],
    elapsedMs: performance.now() - start,
    truncated: false,
  };
}

export class PythonEngineAdapter implements RegexEngineAdapter {
  readonly flavour = "python" as const;
  readonly #runtime: PyodideRuntime;
  readonly #info: RegexEngineInfo;
  #bridge: PythonCallableProxy | undefined;
  #loadPromise: Promise<RegexEngineInfo> | undefined;
  #terminated = false;

  constructor(runtime: PyodideRuntime, runtimeIdentity?: string) {
    this.#runtime = runtime;
    this.#info = pythonEngineInfo(runtimeIdentity);
  }

  load(): Promise<RegexEngineInfo> {
    this.#loadPromise ??= Promise.resolve().then(() => {
      if (this.#terminated) {
        throw new Error("The Python engine adapter has been terminated.");
      }
      if (this.#runtime.version !== PYODIDE_ENGINE_VERSION) {
        throw new Error(
          `Expected Pyodide ${PYODIDE_ENGINE_VERSION}, received ${this.#runtime.version}.`,
        );
      }
      const bridge = this.#bridgeProxy();
      const identity = requireIdentity(
        parseBridgeJson(bridge("identity", "", "", "", 1, 1, "", 1), 16 * 1024),
      );
      if (
        identity.identity.implementation !== "cpython" ||
        identity.identity.pythonVersion !== "3.14.2" ||
        identity.identity.reModule !== "re"
      ) {
        throw new Error(
          "The bundled Python runtime failed its identity self-test.",
        );
      }
      return this.#info;
    });
    return this.#loadPromise;
  }

  async execute(request: RegexExecutionRequest): Promise<RegexExecutionResult> {
    await this.load();
    validateRequest(request);
    const start = performance.now();
    const flags = normalizedPythonFlags(request.flags, request.scanAll);
    const subjectCodePoints = codePointLength(request.subject);
    const parsed = parseBridgeJson(
      this.#bridgeProxy()(
        "execute",
        request.pattern,
        flags.effectiveFlags,
        request.subject,
        request.maximumMatches,
        request.maximumCaptureRows,
        "",
        1,
      ),
      EXECUTION_RESULT_JSON_UTF16_LIMIT,
    );
    const error = parseNativeError(parsed);
    if (error) {
      return failedExecution(
        this.#info,
        flags,
        start,
        diagnosticForError(error, request.pattern),
      );
    }
    const native = requireExecution(parsed, request, subjectCodePoints);
    const materialized = materializeMatches(native, request);
    return {
      accepted: true,
      engine: this.#info,
      flags,
      matches: materialized.matches,
      diagnostics: resultDiagnostics(
        request,
        native,
        materialized.valuesTruncated,
      ),
      elapsedMs: performance.now() - start,
      truncated: native.resultsTruncated,
    };
  }

  async replace(
    request: RegexReplacementRequest,
  ): Promise<RegexReplacementResult> {
    await this.load();
    validateRequest(request, request);
    const start = performance.now();
    const flags = normalizedPythonFlags(request.flags, request.scanAll);
    const subjectCodePoints = codePointLength(request.subject);
    const encoded = parseBridgeJson(
      this.#bridgeProxy()(
        "replace",
        request.pattern,
        flags.effectiveFlags,
        request.subject,
        request.maximumMatches,
        request.maximumCaptureRows,
        request.replacement,
        request.maximumOutputBytes,
      ),
      maximumBase64JsonUtf16(
        EXECUTION_RESULT_JSON_UTF16_LIMIT,
        request.maximumOutputBytes,
      ),
    );
    if (!isRecord(encoded)) {
      throw new Error("The Python bridge returned a non-object JSON value.");
    }
    const parsed = decodeBase64OutputEnvelope(
      encoded,
      request.maximumOutputBytes,
      "The Python bridge",
    );
    const error = parseNativeError(parsed);
    if (error) {
      return {
        execution: failedExecution(
          this.#info,
          flags,
          start,
          diagnosticForError(error, request.pattern),
        ),
        output: "",
        outputBytes: 0,
        outputTruncated: false,
        truncated: false,
      };
    }
    const native = requireExecution(
      parsed,
      request,
      subjectCodePoints,
      request,
    );
    const materialized = materializeMatches(native, request);
    const execution: RegexExecutionResult = {
      accepted: true,
      engine: this.#info,
      flags,
      matches: materialized.matches,
      diagnostics: resultDiagnostics(
        request,
        native,
        materialized.valuesTruncated,
        request,
      ),
      elapsedMs: performance.now() - start,
      truncated: native.resultsTruncated,
    };
    return {
      execution,
      output: native.output ?? "",
      outputBytes: native.outputBytes ?? 0,
      outputTruncated: native.outputTruncated ?? false,
      truncated: native.resultsTruncated || (native.outputTruncated ?? false),
    };
  }

  terminate(): void {
    if (this.#terminated) return;
    this.#terminated = true;
    this.#bridge?.destroy();
    this.#bridge = undefined;
  }

  #bridgeProxy(): PythonCallableProxy {
    if (this.#terminated) {
      throw new Error("The Python engine adapter has been terminated.");
    }
    if (!this.#bridge) {
      const bridge = this.#runtime.runPython(PYTHON_BRIDGE_SOURCE);
      if (
        typeof bridge !== "function" ||
        !("destroy" in bridge) ||
        typeof bridge.destroy !== "function"
      ) {
        throw new Error("The fixed Python bridge did not return a callable.");
      }
      this.#bridge = bridge as PythonCallableProxy;
    }
    return this.#bridge;
  }
}
