packages/client-runtime/src/state/attachments.ts

import {
  PROVIDER_SEND_TURN_MAX_FILE_BYTES,
  WS_METHODS,
  type AttachmentCreateUploadUrlInput,
  type AttachmentCreateUploadUrlResult,
  type AttachmentDeleteInput,
  type EnvironmentId,
} from "@t3tools/contracts";
import type { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";

import type { EnvironmentRegistry } from "../connection/registry.ts";
import {
  createEnvironmentRpcCommand,
  executeAtomQuery,
  runAtomCommand,
  squashAtomCommandFailure,
  type AtomCommand,
} from "./runtime.ts";

/**
 * RPC commands for pending chat attachment uploads. Mirrors
 * `createAssetEnvironmentAtoms`: each client instantiates it with its own
 * connection runtime (`attachmentEnvironment` in web and mobile).
 */
export function createAttachmentEnvironmentAtoms<R, E>(
  runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
) {
  return {
    createUploadUrl: createEnvironmentRpcCommand(runtime, {
      label: "environment-command:attachments:create-upload-url",
      tag: WS_METHODS.attachmentsCreateUploadUrl,
    }),
    remove: createEnvironmentRpcCommand(runtime, {
      label: "environment-command:attachments:delete",
      tag: WS_METHODS.attachmentsDelete,
    }),
  };
}

/**
 * Whether a failed asset lookup means the attachment no longer exists on the
 * server, as opposed to a transient transport failure. Pending uploads expire,
 * so this is the signal to upload the bytes again rather than retry the lookup.
 *
 * A structural `_tag` check rather than a schema check: the squashed cause of
 * a failed RPC is not guaranteed to be a decoded error class instance, only a
 * tagged value.
 */
export function isAssetAttachmentNotFoundFailure(error: unknown): boolean {
  return (
    typeof error === "object" &&
    error !== null &&
    "_tag" in error &&
    error._tag === "AssetAttachmentNotFoundError"
  );
}

export type PersistedAttachmentVerification =
  | { readonly status: "verified" }
  | { readonly status: "missing" }
  | { readonly status: "failed"; readonly error: unknown };

/**
 * Checks that a previously uploaded pending attachment still exists on the
 * server by minting an asset URL for it. `verified` means the send can reuse
 * the stored bytes, `missing` means the upload expired and the bytes must be
 * uploaded again, `failed` means the server could not be asked.
 */
export async function verifyPersistedAttachmentUpload<A, E>(input: {
  readonly registry: AtomRegistry.AtomRegistry;
  readonly createAssetUrl: (query: {
    readonly environmentId: EnvironmentId;
    readonly input: {
      readonly resource: { readonly _tag: "attachment"; readonly attachmentId: string };
    };
  }) => Atom.Atom<AsyncResult.AsyncResult<A, E>>;
  readonly environmentId: EnvironmentId;
  readonly attachmentId: string;
}): Promise<PersistedAttachmentVerification> {
  const result = await executeAtomQuery(
    input.registry,
    input.createAssetUrl({
      environmentId: input.environmentId,
      input: { resource: { _tag: "attachment", attachmentId: input.attachmentId } },
    }),
    // `refresh` forces a server round trip: the asset URL query atom caches
    // results (SWR), so a retry right after a transient failure would
    // otherwise re-observe the cached failure and never ask the server.
    { reportFailure: false, reportDefect: false, refresh: true },
  );
  if (result._tag === "Success") {
    return { status: "verified" };
  }
  const error = squashAtomCommandFailure(result);
  return isAssetAttachmentNotFoundFailure(error)
    ? { status: "missing" }
    : { status: "failed", error };
}

type AttachmentCreateUploadUrlCommand<E> = AtomCommand<
  { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput },
  AttachmentCreateUploadUrlResult,
  E
>;

type AttachmentRemoveCommand<E> = AtomCommand<
  { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput },
  unknown,
  E
>;

/** Fire-and-forget delete of a pending upload the client no longer references. */
export function deletePendingAttachmentUpload<E>(input: {
  readonly registry: AtomRegistry.AtomRegistry;
  readonly remove: AttachmentRemoveCommand<E>;
  readonly environmentId: EnvironmentId;
  readonly attachmentId: string;
}): void {
  void runAtomCommand(
    input.registry,
    input.remove,
    { environmentId: input.environmentId, input: { attachmentId: input.attachmentId } },
    { reportFailure: false, reportDefect: false },
  );
}

/** A running byte transfer: resolves when the server accepted the bytes. */
export interface AttachmentByteUpload {
  readonly done: Promise<void>;
  readonly abort: () => void;
}

export type AttachmentUploadCycleResult =
  | { readonly status: "uploaded"; readonly attachmentId: string }
  | { readonly status: "cancelled"; readonly attachmentId: string | null }
  | {
      readonly status: "failed";
      readonly step: "mint" | "resolve-url" | "transfer";
      readonly attachmentId: string | null;
      readonly error: unknown;
    };

/**
 * The platform-neutral upload cycle: mint a signed upload URL, resolve it
 * against the environment's HTTP base, and hand the bytes to a
 * platform-specific transport (XHR on web, `expo-file-system` on mobile).
 *
 * The cycle never deletes the minted pending upload on failure: callers keep
 * the returned `attachmentId` and decide between retry and release. The one
 * exception is `onMinted` returning `"cancel"`, where the caller already
 * abandoned the upload and the fresh mint is deleted before returning.
 */
export async function runAttachmentUploadCycle<E, RE>(input: {
  readonly registry: AtomRegistry.AtomRegistry;
  readonly createUploadUrl: AttachmentCreateUploadUrlCommand<E>;
  readonly remove: AttachmentRemoveCommand<RE>;
  readonly environmentId: EnvironmentId;
  readonly upload: AttachmentCreateUploadUrlInput;
  readonly resolveUploadUrl: (relativeUrl: string) => string | null;
  readonly transport: (url: string) => AttachmentByteUpload;
  /** Observe the minted id (for cancellation bookkeeping) before bytes move. */
  readonly onMinted?: (attachmentId: string) => "continue" | "cancel";
  readonly onTransferStart?: (abort: () => void) => void;
}): Promise<AttachmentUploadCycleResult> {
  const minted = await runAtomCommand(
    input.registry,
    input.createUploadUrl,
    { environmentId: input.environmentId, input: input.upload },
    { reportFailure: false },
  );
  if (minted._tag !== "Success") {
    return {
      status: "failed",
      step: "mint",
      attachmentId: null,
      error: squashAtomCommandFailure(minted),
    };
  }
  const attachmentId = minted.value.attachmentId;
  if (input.onMinted?.(attachmentId) === "cancel") {
    deletePendingAttachmentUpload({
      registry: input.registry,
      remove: input.remove,
      environmentId: input.environmentId,
      attachmentId,
    });
    return { status: "cancelled", attachmentId };
  }

  const url = input.resolveUploadUrl(minted.value.relativeUrl);
  if (!url) {
    return {
      status: "failed",
      step: "resolve-url",
      attachmentId,
      error: new Error("The environment is not connected."),
    };
  }

  const transfer = input.transport(url);
  input.onTransferStart?.(transfer.abort);
  try {
    await transfer.done;
  } catch (error) {
    return { status: "failed", step: "transfer", attachmentId, error };
  }
  return { status: "uploaded", attachmentId };
}

/**
 * The effective per-file byte limit for a server that advertises
 * `capabilities.fileAttachments.maxUploadBytes`. The contract caps what a
 * turn may reference, so a larger advertised value must not admit files the
 * send would then refuse.
 */
export function clampFileAttachmentUploadBytes(advertisedMaxUploadBytes: number): number {
  return Math.min(advertisedMaxUploadBytes, PROVIDER_SEND_TURN_MAX_FILE_BYTES);
}

/** "3.2 MB" / "48 KB" label for attachment rows. Never shows "0 KB". */
export function formatAttachmentSize(sizeBytes: number): string {
  return sizeBytes >= 1024 * 1024
    ? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
    : `${Math.max(1, Math.ceil(sizeBytes / 1024))} KB`;
}

/** User-facing rejection for a file over the effective upload limit. */
export function fileAttachmentTooLargeMessage(name: string, maxUploadBytes: number): string {
  const maxUploadSize =
    maxUploadBytes >= 1024 * 1024 && maxUploadBytes % (1024 * 1024) === 0
      ? `${maxUploadBytes / (1024 * 1024)} MB`
      : maxUploadBytes >= 1024 && maxUploadBytes % 1024 === 0
        ? `${maxUploadBytes / 1024} KB`
        : `${maxUploadBytes} ${maxUploadBytes === 1 ? "byte" : "bytes"}`;
  return `'${name}' exceeds the ${maxUploadSize} attachment limit.`;
}