/** Canonical JSON value that can cross an event transport boundary. */
export type EventTransportValue =
  | null
  | string
  | number
  | boolean
  | readonly EventTransportValue[]
  | { readonly [key: string]: EventTransportValue };

/** Why an event payload cannot safely cross a serialized transport. */
export type EventTransportErrorReason = "not-json-safe" | "not-stable";

/** Error thrown when an event payload cannot survive canonical JSON transport. */
export class EventTransportError extends Error {
  /** Stable event name. */
  readonly eventName: string;
  /** Whether the value is not JSON-safe or changes under repeated parsing. */
  readonly reason: EventTransportErrorReason;
  /** Payload path associated with a JSON-safety failure. */
  readonly path?: string;

  constructor(args: {
    eventName: string;
    reason: EventTransportErrorReason;
    message: string;
    path?: string;
    cause?: unknown;
  }) {
    super(`Event "${args.eventName}" payload ${args.message}`, {
      cause: args.cause,
    });
    this.name = "EventTransportError";
    this.eventName = args.eventName;
    this.reason = args.reason;
    this.path = args.path;
  }
}

function childTransportPath(path: string, key: string): string {
  return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
    ? `${path}.${key}`
    : `${path}[${JSON.stringify(key)}]`;
}

function throwJsonSafetyError(args: {
  eventName: string;
  path: string;
  message: string;
}): never {
  throw new EventTransportError({
    eventName: args.eventName,
    reason: "not-json-safe",
    path: args.path,
    message: `is not transport-safe at ${args.path}: ${args.message}`,
  });
}

export function toEventTransportValue(
  eventName: string,
  value: unknown,
  path = "payload",
  seen: WeakSet<object> = new WeakSet(),
): EventTransportValue {
  if (value === null) return null;

  if (typeof value === "string" || typeof value === "boolean") return value;
  if (typeof value === "number") {
    if (!Number.isFinite(value)) {
      return throwJsonSafetyError({
        eventName,
        path,
        message: "numbers must be finite.",
      });
    }
    return Object.is(value, -0) ? 0 : value;
  }

  if (typeof value !== "object") {
    return throwJsonSafetyError({
      eventName,
      path,
      message: `received ${typeof value}; use null, strings, finite numbers, booleans, arrays, or plain objects.`,
    });
  }

  try {
    if (value instanceof Date) {
      return throwJsonSafetyError({
        eventName,
        path,
        message: "Date values are not supported; use an ISO string or number.",
      });
    }

    if (seen.has(value)) {
      return throwJsonSafetyError({
        eventName,
        path,
        message: "circular references are not supported.",
      });
    }

    seen.add(value);
    if (Array.isArray(value)) {
      const output: EventTransportValue[] = [];
      const ownKeys = Reflect.ownKeys(value);
      const hasUnsupportedKey = ownKeys.some((key) => {
        if (key === "length") return false;
        if (typeof key !== "string") return true;
        const index = Number(key);
        return (
          !Number.isInteger(index) ||
          index < 0 ||
          index >= value.length ||
          String(index) !== key
        );
      });
      if (hasUnsupportedKey) {
        return throwJsonSafetyError({
          eventName,
          path,
          message: "arrays cannot contain symbol or custom properties.",
        });
      }
      if (ownKeys.length !== value.length + 1) {
        return throwJsonSafetyError({
          eventName,
          path,
          message: "sparse arrays are not supported.",
        });
      }
      for (let index = 0; index < value.length; index += 1) {
        const key = String(index);
        const itemPath = `${path}[${index}]`;
        const descriptor = Object.getOwnPropertyDescriptor(value, key);
        if (!descriptor) {
          return throwJsonSafetyError({
            eventName,
            path: itemPath,
            message: "sparse arrays are not supported.",
          });
        }
        if (!("value" in descriptor) || !descriptor.enumerable) {
          return throwJsonSafetyError({
            eventName,
            path: itemPath,
            message: "array items must be enumerable data properties.",
          });
        }
        output.push(
          toEventTransportValue(eventName, descriptor.value, itemPath, seen),
        );
      }
      return output;
    }

    const prototype = Object.getPrototypeOf(value);
    if (prototype !== Object.prototype && prototype !== null) {
      return throwJsonSafetyError({
        eventName,
        path,
        message: "objects must be plain objects.",
      });
    }

    const output = {} as Record<string, EventTransportValue>;
    for (const key of Reflect.ownKeys(value)) {
      if (typeof key !== "string") {
        return throwJsonSafetyError({
          eventName,
          path,
          message: "symbol properties are not supported.",
        });
      }
      const propertyPath = childTransportPath(path, key);
      const descriptor = Object.getOwnPropertyDescriptor(value, key);
      if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
        return throwJsonSafetyError({
          eventName,
          path: propertyPath,
          message: "properties must be enumerable data properties.",
        });
      }
      Object.defineProperty(output, key, {
        configurable: true,
        enumerable: true,
        writable: true,
        value: toEventTransportValue(
          eventName,
          descriptor.value,
          propertyPath,
          seen,
        ),
      });
    }
    return output;
  } catch (error) {
    if (error instanceof EventTransportError) throw error;
    throw new EventTransportError({
      eventName,
      reason: "not-json-safe",
      path,
      message: `could not be inspected as transport data at ${path}.`,
      cause: error,
    });
  } finally {
    seen.delete(value);
  }
}

export function eventTransportValuesEqual(
  left: EventTransportValue,
  right: EventTransportValue,
): boolean {
  if (left === right) return true;
  if (left === null || right === null || typeof left !== typeof right) {
    return false;
  }
  if (Array.isArray(left) || Array.isArray(right)) {
    if (!Array.isArray(left) || !Array.isArray(right)) return false;
    return (
      left.length === right.length &&
      left.every((value, index) =>
        eventTransportValuesEqual(value, right[index]),
      )
    );
  }
  if (typeof left !== "object" || typeof right !== "object") return false;

  const leftRecord = left as Readonly<Record<string, EventTransportValue>>;
  const rightRecord = right as Readonly<Record<string, EventTransportValue>>;
  const leftKeys = Object.keys(leftRecord).sort();
  const rightKeys = Object.keys(rightRecord).sort();
  return (
    leftKeys.length === rightKeys.length &&
    leftKeys.every(
      (key, index) =>
        key === rightKeys[index] &&
        eventTransportValuesEqual(leftRecord[key], rightRecord[key]),
    )
  );
}
