import type {
  CompleteUploadResult,
  InferUploadMetadata,
  InferUploadResult,
  PreparedUploadFile,
  PreparedUploadResultFile,
  PrepareUploadResult,
  UploadDef,
  UploadFileConstraints,
  UploadFileIntent,
  UploadFromRegistry,
  UploadManifestEntry,
  UploadRegistry,
} from "./index.js";

/**
 * Upload registry shape accepted by the typed browser client.
 *
 * Pass the type of a `defineUploads(...)` registry, or an upload definition
 * array, to `createUploadClient<...>()`.
 */
export type UploadClientRegistry = UploadRegistry | readonly UploadDef[];

/**
 * Union of upload route names available in an upload registry.
 */
export type UploadClientName<Registry> =
  UploadFromRegistry<Registry> extends UploadDef<infer Name>
    ? Name & string
    : never;

/**
 * Find one upload definition in a registry by its route name.
 */
export type UploadByName<Registry, Name extends string> =
  UploadFromRegistry<Registry> extends infer Upload
    ? Upload extends UploadDef
      ? Upload extends { readonly name: Name }
        ? Upload
        : never
      : never
    : never;

/**
 * Transport strategy used by `upload(...)`.
 */
export type UploadClientStrategy = "auto" | "direct" | "server";

/**
 * Fetch-compatible function used for upload route and direct provider
 * requests.
 */
export type UploadClientFetch = (
  input: RequestInfo | URL,
  init?: RequestInit,
) => Promise<Response>;

/**
 * Static or lazy headers sent to Beignet upload route requests.
 */
export type UploadClientHeaders =
  | HeadersInit
  | (() => HeadersInit | Promise<HeadersInit>);

/**
 * Request options shared by Beignet upload route requests.
 */
export interface UploadClientRequestOptions {
  /**
   * Browser credential policy for upload route requests.
   */
  credentials?: RequestCredentials;
  /**
   * Browser request mode for upload route requests.
   */
  mode?: RequestMode;
  /**
   * Browser cache policy for upload route requests.
   */
  cache?: RequestCache;
}

/**
 * Options for `createUploadClient(...)`.
 */
export interface CreateUploadClientOptions {
  /**
   * Base URL for the upload route.
   *
   * @default "/api/uploads"
   */
  baseUrl?: string;
  /**
   * Fetch implementation used for Beignet upload route requests.
   */
  fetch?: UploadClientFetch;
  /**
   * Headers sent to Beignet upload route requests. These are not sent to
   * provider-owned direct upload URLs.
   */
  headers?: UploadClientHeaders;
  /**
   * Request options shared by Beignet upload route requests.
   */
  request?: UploadClientRequestOptions;
  /**
   * Optional client-safe upload metadata for UI helpers.
   */
  manifest?: readonly UploadManifestEntry[];
}

/**
 * File lifecycle event emitted by direct and server upload helpers.
 */
export interface UploadClientFileEvent {
  /**
   * Browser `File` object being uploaded.
   */
  file: File;
  /**
   * File name supplied by the browser.
   */
  fileName: string;
  /**
   * Zero-based index from the files array passed by the caller.
   */
  index: number;
  /**
   * Prepared upload id when the file has gone through `prepare(...)`.
   */
  uploadId?: string;
  /**
   * Storage key when the file has gone through `prepare(...)`.
   */
  key?: string;
}

/**
 * Upload progress event emitted while a direct upload is in flight.
 */
export interface UploadClientProgressEvent extends UploadClientFileEvent {
  /**
   * Uploaded bytes.
   */
  loaded: number;
  /**
   * Total bytes expected for the file.
   */
  total: number;
  /**
   * Fraction from `0` to `1`.
   */
  progress: number;
}

/**
 * Per-call options shared by upload client route requests.
 */
export interface UploadClientRouteOptions {
  /**
   * Additional headers sent to Beignet upload route requests.
   */
  headers?: UploadClientHeaders;
  /**
   * Additional request options sent to Beignet upload route requests.
   */
  request?: UploadClientRequestOptions;
  /**
   * Abort signal used for route requests and direct uploads.
   */
  signal?: AbortSignal;
}

/**
 * Options for preparing an upload.
 */
export interface UploadClientPrepareOptions<Upload extends UploadDef>
  extends UploadClientRouteOptions {
  /**
   * Metadata validated by the upload definition.
   */
  metadata: InferUploadMetadata<Upload>;
  /**
   * Browser files to upload.
   */
  files: readonly File[];
}

/**
 * Options for direct, server, or automatic upload execution.
 */
export interface UploadClientUploadOptions<Upload extends UploadDef>
  extends UploadClientPrepareOptions<Upload> {
  /**
   * Upload transport strategy. Auto probes for direct-upload instructions and
   * falls back to server-handled multipart upload.
   *
   * @default "auto"
   */
  strategy?: UploadClientStrategy;
  /**
   * Called when a file is about to be uploaded.
   */
  onFileBegin?(event: UploadClientFileEvent): void;
  /**
   * Called with upload progress. Direct uploads use XHR when this callback is
   * provided so browsers can report progress events.
   */
  onProgress?(event: UploadClientProgressEvent): void;
}

/**
 * Browser upload client typed by an upload registry.
 */
export interface UploadClient<Registry extends UploadClientRegistry> {
  /**
   * Validate metadata and file intent, authorize the upload, and receive
   * storage keys plus direct upload instructions when a signer is configured.
   */
  prepare<Name extends UploadClientName<Registry>>(
    uploadName: Name,
    options: UploadClientPrepareOptions<UploadByName<Registry, Name>>,
  ): Promise<PrepareUploadResult>;
  /**
   * Complete a prepared direct upload after objects have been written to
   * storage.
   */
  complete<Name extends UploadClientName<Registry>>(
    uploadName: Name,
    options: UploadClientRouteOptions & {
      metadata: InferUploadMetadata<UploadByName<Registry, Name>>;
      files: readonly PreparedUploadFile[];
    },
  ): Promise<
    CompleteUploadResult<InferUploadResult<UploadByName<Registry, Name>>>
  >;
  /**
   * Upload files through the Beignet application server using multipart form
   * data.
   */
  server<Name extends UploadClientName<Registry>>(
    uploadName: Name,
    options: UploadClientUploadOptions<UploadByName<Registry, Name>>,
  ): Promise<
    CompleteUploadResult<InferUploadResult<UploadByName<Registry, Name>>>
  >;
  /**
   * Require a direct provider upload flow: prepare, PUT each file to its
   * provider URL, then complete.
   */
  direct<Name extends UploadClientName<Registry>>(
    uploadName: Name,
    options: UploadClientUploadOptions<UploadByName<Registry, Name>>,
  ): Promise<
    CompleteUploadResult<InferUploadResult<UploadByName<Registry, Name>>>
  >;
  /**
   * Upload using the selected strategy. The default `"auto"` strategy uses a
   * direct provider flow when available and falls back to server multipart.
   */
  upload<Name extends UploadClientName<Registry>>(
    uploadName: Name,
    options: UploadClientUploadOptions<UploadByName<Registry, Name>>,
  ): Promise<
    CompleteUploadResult<InferUploadResult<UploadByName<Registry, Name>>>
  >;
  /**
   * Return manifest-backed file constraints for UI controls.
   */
  constraints<Name extends UploadClientName<Registry>>(
    uploadName: Name,
  ): UploadFileConstraints | undefined;
  /**
   * Return a comma-delimited file input `accept` value from manifest content
   * types.
   */
  accept<Name extends UploadClientName<Registry>>(
    uploadName: Name,
  ): string | undefined;
}

/**
 * Constructor options for `UploadClientError`.
 */
export interface UploadClientErrorOptions {
  /**
   * Client operation that failed.
   */
  operation: string;
  /**
   * Upload route name involved in the failure.
   */
  uploadName: string;
  /**
   * Human-readable error message.
   */
  message: string;
  /**
   * HTTP status when a route or provider response was received.
   */
  status?: number;
  /**
   * Machine-readable error code.
   */
  code?: string;
  /**
   * Structured error details from the upload route, when available.
   */
  details?: unknown;
  /**
   * Original error that caused the client failure.
   */
  cause?: unknown;
}

/**
 * Error thrown by upload client route requests or direct provider uploads.
 */
export class UploadClientError extends Error {
  /**
   * Client operation that failed.
   */
  readonly operation: string;
  /**
   * Upload route name involved in the failure.
   */
  readonly uploadName: string;
  /**
   * HTTP status when a route or provider response was received.
   */
  readonly status?: number;
  /**
   * Machine-readable error code.
   */
  readonly code?: string;
  /**
   * Structured error details from the upload route, when available.
   */
  readonly details?: unknown;

  /**
   * Create an upload client error.
   */
  constructor(options: UploadClientErrorOptions) {
    super(options.message, { cause: options.cause });
    this.name = "UploadClientError";
    this.operation = options.operation;
    this.uploadName = options.uploadName;
    this.status = options.status;
    this.code = options.code;
    this.details = options.details;
  }
}

/**
 * Create a typed browser upload client for a Beignet upload route.
 */
export function createUploadClient<
  Registry extends UploadClientRegistry = UploadRegistry,
>(options: CreateUploadClientOptions = {}): UploadClient<Registry> {
  const baseUrl = normalizeBaseUrl(options.baseUrl ?? "/api/uploads");
  const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
  if (!fetchImpl) {
    throw new Error("createUploadClient requires a fetch implementation.");
  }

  async function routeHeaders(
    routeOptions?: UploadClientRouteOptions,
    contentType?: string,
  ): Promise<Headers> {
    const headers = new Headers();
    if (contentType) headers.set("content-type", contentType);

    const shared = await resolveHeaders(options.headers);
    const local = await resolveHeaders(routeOptions?.headers);
    mergeHeaders(headers, shared);
    mergeHeaders(headers, local);
    return headers;
  }

  function routeRequest(
    routeOptions?: UploadClientRouteOptions,
  ): UploadClientRequestOptions {
    return {
      ...options.request,
      ...routeOptions?.request,
    };
  }

  async function prepare<Name extends UploadClientName<Registry>>(
    uploadName: Name,
    prepareOptions: UploadClientPrepareOptions<UploadByName<Registry, Name>>,
  ) {
    const fileConstraints = constraints(uploadName);
    const files = await Promise.all(
      prepareOptions.files.map((file) =>
        fileIntentFromFile({
          file,
          constraints: fileConstraints,
          uploadName,
        }),
      ),
    );

    return requestJson<PrepareUploadResult>({
      fetchImpl,
      url: actionUrl(baseUrl, uploadName, "prepare"),
      operation: "prepare upload",
      uploadName,
      init: {
        ...routeRequest(prepareOptions),
        method: "POST",
        headers: await routeHeaders(prepareOptions, "application/json"),
        signal: prepareOptions.signal,
        body: JSON.stringify({
          metadata: prepareOptions.metadata,
          files,
        }),
      },
    });
  }

  async function complete<Name extends UploadClientName<Registry>>(
    uploadName: Name,
    completeOptions: UploadClientRouteOptions & {
      metadata: InferUploadMetadata<UploadByName<Registry, Name>>;
      files: readonly PreparedUploadFile[];
    },
  ) {
    return requestJson<
      CompleteUploadResult<InferUploadResult<UploadByName<Registry, Name>>>
    >({
      fetchImpl,
      url: actionUrl(baseUrl, uploadName, "complete"),
      operation: "complete upload",
      uploadName,
      init: {
        ...routeRequest(completeOptions),
        method: "POST",
        headers: await routeHeaders(completeOptions, "application/json"),
        signal: completeOptions.signal,
        body: JSON.stringify({
          metadata: completeOptions.metadata,
          files: completeOptions.files.map(completeFileInput),
        }),
      },
    });
  }

  async function server<Name extends UploadClientName<Registry>>(
    uploadName: Name,
    uploadOptions: UploadClientUploadOptions<UploadByName<Registry, Name>>,
  ) {
    const formData = new FormData();
    formData.set("metadata", JSON.stringify(uploadOptions.metadata));
    uploadOptions.files.forEach((file) => {
      formData.append("file", file);
    });
    uploadOptions.files.forEach((file, index) => {
      uploadOptions.onFileBegin?.({
        file,
        fileName: file.name,
        index,
      });
    });

    const result = await requestJson<
      CompleteUploadResult<InferUploadResult<UploadByName<Registry, Name>>>
    >({
      fetchImpl,
      url: actionUrl(baseUrl, uploadName, "upload"),
      operation: "server upload",
      uploadName,
      init: {
        ...routeRequest(uploadOptions),
        method: "POST",
        headers: await routeHeaders(uploadOptions),
        signal: uploadOptions.signal,
        body: formData,
      },
    });

    uploadOptions.files.forEach((file, index) => {
      uploadOptions.onProgress?.({
        file,
        fileName: file.name,
        index,
        loaded: file.size,
        total: file.size,
        progress: 1,
      });
    });
    return result;
  }

  async function direct<Name extends UploadClientName<Registry>>(
    uploadName: Name,
    uploadOptions: UploadClientUploadOptions<UploadByName<Registry, Name>>,
  ) {
    const prepared = await prepare(uploadName, uploadOptions);
    return directFromPrepared(uploadName, uploadOptions, prepared);
  }

  async function directFromPrepared<Name extends UploadClientName<Registry>>(
    uploadName: Name,
    uploadOptions: UploadClientUploadOptions<UploadByName<Registry, Name>>,
    prepared: PrepareUploadResult,
  ) {
    if (prepared.mode !== "direct") {
      throw new UploadClientError({
        operation: "direct upload",
        uploadName,
        code: "DIRECT_UPLOAD_UNAVAILABLE",
        message: `Upload "${uploadName}" did not return direct upload instructions.`,
      });
    }

    await Promise.all(
      prepared.files.map((preparedFile, index) =>
        uploadDirectFile({
          fetchImpl,
          uploadName,
          preparedFile,
          file: uploadOptions.files[index],
          index,
          signal: uploadOptions.signal,
          onFileBegin: uploadOptions.onFileBegin,
          onProgress: uploadOptions.onProgress,
        }),
      ),
    );

    return complete(uploadName, {
      metadata: uploadOptions.metadata,
      files: prepared.files,
      headers: uploadOptions.headers,
      request: uploadOptions.request,
      signal: uploadOptions.signal,
    });
  }

  async function upload<Name extends UploadClientName<Registry>>(
    uploadName: Name,
    uploadOptions: UploadClientUploadOptions<UploadByName<Registry, Name>>,
  ) {
    const strategy = uploadOptions.strategy ?? "auto";
    if (strategy === "server") return server(uploadName, uploadOptions);
    if (strategy === "direct") return direct(uploadName, uploadOptions);

    const prepared = await prepare(uploadName, uploadOptions);
    if (prepared.mode === "direct") {
      return directFromPrepared(uploadName, uploadOptions, prepared);
    }
    return server(uploadName, uploadOptions);
  }

  function constraints<Name extends UploadClientName<Registry>>(
    uploadName: Name,
  ) {
    return options.manifest?.find((entry) => entry.name === uploadName)?.file;
  }

  function accept<Name extends UploadClientName<Registry>>(uploadName: Name) {
    return constraints(uploadName)?.contentTypes?.join(",");
  }

  return {
    prepare,
    complete,
    server,
    direct,
    upload,
    constraints,
    accept,
  };
}

async function fileIntentFromFile(args: {
  file: File;
  constraints: UploadFileConstraints | undefined;
  uploadName: string;
}): Promise<UploadFileIntent> {
  const contentType =
    normalizeContentType(args.file.type) || "application/octet-stream";
  const intent: UploadFileIntent = {
    name: args.file.name,
    contentType,
    size: args.file.size,
  };

  const checksumRequirement = args.constraints?.checksum;
  if (checksumRequirement?.algorithm === "sha256") {
    intent.checksum = await createClientUploadChecksum(args.file, {
      uploadName: args.uploadName,
      required: checksumRequirement.required !== false,
    });
  }

  return intent;
}

function completeFileInput(file: PreparedUploadResultFile): PreparedUploadFile {
  return {
    name: file.name,
    contentType: file.contentType,
    size: file.size,
    ...(file.checksum ? { checksum: file.checksum } : {}),
    uploadId: file.uploadId,
    key: file.key,
  };
}

async function createClientUploadChecksum(
  file: File,
  options: { uploadName: string; required: boolean },
): Promise<UploadFileIntent["checksum"]> {
  if (!globalThis.crypto?.subtle) {
    if (!options.required) return undefined;

    throw new UploadClientError({
      operation: "prepare upload",
      uploadName: options.uploadName,
      code: "UPLOAD_CHECKSUM_UNAVAILABLE",
      message: `Upload "${options.uploadName}" requires Web Crypto to compute checksums.`,
    });
  }

  const digest = await globalThis.crypto.subtle.digest(
    "SHA-256",
    await file.arrayBuffer(),
  );
  return {
    algorithm: "sha256",
    value: [...new Uint8Array(digest)]
      .map((byte) => byte.toString(16).padStart(2, "0"))
      .join(""),
  };
}

function normalizeBaseUrl(baseUrl: string): string {
  return baseUrl.replace(/\/+$/, "");
}

function normalizeContentType(contentType: string): string {
  return contentType.split(";")[0]?.trim().toLowerCase() ?? "";
}

function actionUrl(
  baseUrl: string,
  uploadName: string,
  action: string,
): string {
  return `${baseUrl}/${encodeURIComponent(uploadName)}/${action}`;
}

async function resolveHeaders(
  headers: UploadClientHeaders | undefined,
): Promise<HeadersInit | undefined> {
  return typeof headers === "function" ? headers() : headers;
}

function mergeHeaders(target: Headers, source: HeadersInit | undefined): void {
  if (!source) return;
  new Headers(source).forEach((value, key) => {
    target.set(key, value);
  });
}

async function requestJson<Result>(args: {
  fetchImpl: UploadClientFetch;
  url: string;
  operation: string;
  uploadName: string;
  init: RequestInit;
}): Promise<Result> {
  let response: Response;
  try {
    response = await args.fetchImpl(args.url, args.init);
  } catch (error) {
    throw new UploadClientError({
      operation: args.operation,
      uploadName: args.uploadName,
      code: "UPLOAD_REQUEST_FAILED",
      message: `Failed to ${args.operation} "${args.uploadName}".`,
      cause: error,
    });
  }

  const body = await parseJsonBody(response, args);
  if (!response.ok) {
    const envelope = uploadErrorBody(body);
    throw new UploadClientError({
      operation: args.operation,
      uploadName: args.uploadName,
      status: response.status,
      code: envelope?.code ?? "UPLOAD_REQUEST_FAILED",
      message:
        envelope?.message ??
        `Failed to ${args.operation} "${args.uploadName}" (${response.status}).`,
      details: envelope?.details,
    });
  }

  return body as Result;
}

async function parseJsonBody(
  response: Response,
  args: { operation: string; uploadName: string },
): Promise<unknown> {
  const text = await response.text();
  if (!text) return undefined;

  try {
    return JSON.parse(text);
  } catch (error) {
    throw new UploadClientError({
      operation: args.operation,
      uploadName: args.uploadName,
      status: response.status,
      code: "INVALID_UPLOAD_RESPONSE",
      message: `Failed to parse upload response for "${args.uploadName}".`,
      cause: error,
    });
  }
}

function uploadErrorBody(
  body: unknown,
): { code?: string; message?: string; details?: unknown } | undefined {
  if (typeof body !== "object" || body === null) {
    return undefined;
  }

  const errorRecord = body as Record<string, unknown>;

  return {
    code: typeof errorRecord.code === "string" ? errorRecord.code : undefined,
    message:
      typeof errorRecord.message === "string" ? errorRecord.message : undefined,
    ...("details" in errorRecord ? { details: errorRecord.details } : {}),
  };
}

async function uploadDirectFile(args: {
  fetchImpl: UploadClientFetch;
  uploadName: string;
  preparedFile: PreparedUploadResultFile;
  file: File | undefined;
  index: number;
  signal?: AbortSignal;
  onFileBegin?(event: UploadClientFileEvent): void;
  onProgress?(event: UploadClientProgressEvent): void;
}): Promise<void> {
  const file = args.file;
  if (!file) {
    throw new UploadClientError({
      operation: "direct upload",
      uploadName: args.uploadName,
      code: "INVALID_UPLOAD_FILE",
      message: `Missing browser file for prepared upload "${args.preparedFile.key}".`,
    });
  }

  if (!args.preparedFile.direct) {
    throw new UploadClientError({
      operation: "direct upload",
      uploadName: args.uploadName,
      code: "DIRECT_UPLOAD_UNAVAILABLE",
      message: `Upload "${args.uploadName}" did not include direct instructions for "${args.preparedFile.name}".`,
    });
  }

  args.onFileBegin?.({
    file,
    fileName: file.name,
    index: args.index,
    uploadId: args.preparedFile.uploadId,
    key: args.preparedFile.key,
  });

  if (args.onProgress && typeof XMLHttpRequest !== "undefined") {
    await uploadWithXhr({
      uploadName: args.uploadName,
      preparedFile: args.preparedFile,
      file,
      index: args.index,
      signal: args.signal,
      onProgress: args.onProgress,
    });
    return;
  }

  const response = await args.fetchImpl(args.preparedFile.direct.url, {
    method: args.preparedFile.direct.method,
    headers: args.preparedFile.direct.headers,
    signal: args.signal,
    body: file,
  });
  if (!response.ok) {
    throw new UploadClientError({
      operation: "direct upload",
      uploadName: args.uploadName,
      status: response.status,
      code: "DIRECT_UPLOAD_FAILED",
      message: `Direct upload failed for "${args.preparedFile.name}" (${response.status}).`,
    });
  }

  args.onProgress?.({
    file,
    fileName: file.name,
    index: args.index,
    uploadId: args.preparedFile.uploadId,
    key: args.preparedFile.key,
    loaded: file.size,
    total: file.size,
    progress: 1,
  });
}

function uploadWithXhr(args: {
  uploadName: string;
  preparedFile: PreparedUploadResultFile;
  file: File;
  index: number;
  signal?: AbortSignal;
  onProgress?(event: UploadClientProgressEvent): void;
}): Promise<void> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const direct = args.preparedFile.direct;
    if (!direct) {
      reject(
        new UploadClientError({
          operation: "direct upload",
          uploadName: args.uploadName,
          code: "DIRECT_UPLOAD_UNAVAILABLE",
          message: `Upload "${args.uploadName}" did not include direct instructions for "${args.preparedFile.name}".`,
        }),
      );
      return;
    }

    const abort = () => xhr.abort();
    args.signal?.addEventListener("abort", abort, { once: true });

    xhr.upload.onprogress = (event) => {
      const total = event.lengthComputable ? event.total : args.file.size;
      args.onProgress?.({
        file: args.file,
        fileName: args.file.name,
        index: args.index,
        uploadId: args.preparedFile.uploadId,
        key: args.preparedFile.key,
        loaded: event.loaded,
        total,
        progress: total > 0 ? event.loaded / total : 0,
      });
    };
    xhr.onload = () => {
      args.signal?.removeEventListener("abort", abort);
      if (xhr.status >= 200 && xhr.status < 300) {
        args.onProgress?.({
          file: args.file,
          fileName: args.file.name,
          index: args.index,
          uploadId: args.preparedFile.uploadId,
          key: args.preparedFile.key,
          loaded: args.file.size,
          total: args.file.size,
          progress: 1,
        });
        resolve();
        return;
      }

      reject(
        new UploadClientError({
          operation: "direct upload",
          uploadName: args.uploadName,
          status: xhr.status,
          code: "DIRECT_UPLOAD_FAILED",
          message: `Direct upload failed for "${args.preparedFile.name}" (${xhr.status}).`,
        }),
      );
    };
    xhr.onerror = () => {
      args.signal?.removeEventListener("abort", abort);
      reject(
        new UploadClientError({
          operation: "direct upload",
          uploadName: args.uploadName,
          code: "DIRECT_UPLOAD_FAILED",
          message: `Direct upload failed for "${args.preparedFile.name}".`,
        }),
      );
    };
    xhr.onabort = () => {
      args.signal?.removeEventListener("abort", abort);
      reject(
        new UploadClientError({
          operation: "direct upload",
          uploadName: args.uploadName,
          code: "DIRECT_UPLOAD_ABORTED",
          message: `Direct upload was aborted for "${args.preparedFile.name}".`,
        }),
      );
    };

    xhr.open(direct.method, direct.url);
    for (const [key, value] of Object.entries(direct.headers ?? {})) {
      xhr.setRequestHeader(key, value);
    }
    xhr.send(args.file);
  });
}
