/**
 * Tools to integrate Convex into React applications.
 *
 * This module contains:
 * 1. {@link ConvexReactClient}, a client for using Convex in React.
 * 2. {@link ConvexProvider}, a component that stores this client in React context.
 * 2. [Hooks](https://docs.convex.dev/generated-api/react#react-hooks) for calling into
 *    this client within your React components.
 *
 * ## Usage
 *
 * ### Creating the Client
 *
 * ```typescript
 * import { ConvexReactClient } from "convex/react";
 * import clientConfig from "../convex/_generated/clientConfig";
 *
 * const convex = new ConvexReactClient(clientConfig);
 * ```
 *
 * ### Storing the Client In React Context
 *
 * ```typescript
 * import { ConvexProvider } from "convex/react";
 *
 * <ConvexProvider client={convex}>
 *   <App />
 * </ConvexProvider>
 * ```
 *
 * ### Generating the Hooks
 *
 * This module is typically used alongside generated hooks.
 *
 * To generate the hooks, run `npx convex codegen` in your Convex project. This
 * will create a `convex/_generated/react.js` file with the following React
 * hooks, typed for your queries and mutations:
 * - [useQuery](https://docs.convex.dev/generated-api/react#usequery)
 * - [useMutation](https://docs.convex.dev/generated-api/react#usemutation)
 * - [useConvex](https://docs.convex.dev/generated-api/react#useconvex)
 *
 * If you aren't using code generation, you can use these untyped hooks instead:
 * - {@link useQueryGeneric}
 * - {@link useMutationGeneric}
 * - {@link useConvexGeneric}
 *
 * ### Using the Hooks
 *
 * ```typescript
 * import { useQuery, useMutation } from "../convex/_generated/react";
 *
 * function App() {
 *   const counter = useQuery("getCounter");
 *   const increment = useMutation("incrementCounter");
 *   // Your component here!
 * }
 * ```
 * @module
 */

import {
  GenericAPI,
  InternalConvexClient,
  MutationNames,
  NamedMutation,
  NamedQuery,
  QueryNames,
} from "../browser/index.js";
import type { OptimisticUpdate, QueryToken } from "../browser/index.js";
import React, { useContext, useMemo } from "react";
import { convexToJson } from "@convex-dev/common";
import ReactDOM from "react-dom";
import { useSubscription } from "./use_subscription.js";
import { ClientConfiguration } from "../browser/client_config.js";

// TODO add runtime check that React version is good too.
if (typeof React === "undefined") {
  throw new Error("Required dependency 'react' not installed");
}
if (typeof ReactDOM === "undefined") {
  throw new Error("Required dependency 'react-dom' not installed");
}

// TODO Typedoc doesn't generate documentation for the comment below perhaps
// because it's a callable interface.
/**
 * An interface to execute a Convex mutation function on the server.
 *
 * @public
 */
export interface ReactMutation<
  API extends GenericAPI,
  Name extends MutationNames<API>
> {
  /**
   * Execute the mutation on the server, returning a `Promise` of its return value.
   *
   * @param args - Arguments for the mutation to pass up to the server.
   * @returns The return value of the server-side function call.
   */
  (...args: Parameters<NamedMutation<API, Name>>): Promise<
    ReturnType<NamedMutation<API, Name>>
  >;

  /**
   * Define an optimistic update to apply as part of this mutation.
   *
   * This is a temporary update to the local query results to facilitate a
   * fast, interactive UI. It enables query results to update before a mutation
   * executed on the server.
   *
   * When the mutation is invoked, the optimistic update will be applied.
   *
   * Optimistic updates can also be used to temporarily remove queries from the
   * client and create loading experiences until a mutation completes and the
   * new query results are synced.
   *
   * The update will be automatically rolled back when the mutation is fully
   * completed and queries have been updated.
   *
   * @param optimisticUpdate - The optimistic update to apply.
   * @returns A new `ReactMutation` with the update configured.
   *
   * @public
   */
  withOptimisticUpdate(
    optimisticUpdate: OptimisticUpdate<
      API,
      Parameters<NamedMutation<API, Name>>
    >
  ): ReactMutation<API, Name>;
}

function createMutation<
  API extends GenericAPI,
  Name extends MutationNames<API>
>(
  name: Name,
  sync: () => InternalConvexClient,
  update: OptimisticUpdate<
    API,
    Parameters<NamedMutation<API, Name>>
  > | null = null
): ReactMutation<API, Name> {
  function mutation(
    ...args: Parameters<NamedMutation<API, Name>>
  ): Promise<ReturnType<NamedMutation<API, Name>>> {
    return sync().mutate(name, args, update);
  }
  mutation.withOptimisticUpdate = function withOptimisticUpdate(
    optimisticUpdate: OptimisticUpdate<
      API,
      Parameters<NamedMutation<API, Name>>
    >
  ): ReactMutation<API, Name> {
    if (update !== null) {
      throw new Error(
        `Already specified optimistic update for mutation ${name}`
      );
    }
    return createMutation(name, sync, optimisticUpdate);
  };
  return mutation;
}

/**
 * A watch on the output of a Convex query function.
 *
 * @public
 */
export interface Watch<F extends (...args: any[]) => any> {
  /**
   * Initiate a watch on the output of a query.
   *
   * This will subscribe to this query and call
   * the callback whenever the query result changes.
   *
   * **Important: If the query is already known on the client this watch will
   * never be invoked.** To get the current, local result call
   * {@link react.Watch.localQueryResult}.
   *
   * @param callback - Function that is called whenever the query result changes.
   * @returns - A function that disposes of the subscription.
   */
  onUpdate(callback: () => void): () => void;

  /**
   * Get the current result of a query.
   *
   * This will only return a result if we're already subscribed to the query
   * and have received a result from the server or the query value has been set
   * optimistically.
   *
   * @returns The result of the query or `undefined` if it isn't known.
   * @throws An error if the query encountered an error on the server.
   */
  localQueryResult(): ReturnType<F> | undefined;
}

/**
 * Options for {@link ConvexReactClient}.
 *
 * @public
 */
export type ReactClientOptions = {
  /**
   * Whether to prompt the user that have unsaved changes pending
   * when navigating away or closing a web page with pending Convex mutations.
   * This is only possible when the `window` object exists, i.e. in a browser.
   * The default value is `true`.
   */
  unsavedChangesWarning?: boolean;
  /**
   * Specifies an alternate [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) constructor to use for client communication with the Convex cloud. The default behavior is to use `WebSocket` from the global environment.
   */
  webSocketConstructor?: typeof WebSocket;
};

const DEFAULT_OPTIONS: ReactClientOptions = {
  unsavedChangesWarning: true,
};

/**
 * A Convex client for use within React.
 *
 * This loads reactive queries and executes mutations over a WebSocket.
 *
 * @typeParam API - The API of your application, composed of all Convex queries
 * and mutations. `npx convex codegen` [generates this type](/generated-api/react#convexapi)
 * in `convex/_generated/react.d.ts`.
 * @public
 */
export class ConvexReactClient<API extends GenericAPI> {
  private clientConfig: ClientConfiguration;
  private cachedSync?: InternalConvexClient;
  private listeners: Map<QueryToken, Set<() => void>>;
  private options: ReactClientOptions;
  private closed = false;

  private adminAuth?: string;

  /**
   * @param clientConfig - The generated client configuration for your project.
   * You can find this in `convex/_generated/clientConfig.js`.
   * @param options - See {@link ReactClientOptions} for a full description.
   */
  constructor(clientConfig: ClientConfiguration, options?: ReactClientOptions) {
    this.clientConfig = clientConfig;
    this.listeners = new Map();
    this.options = { ...DEFAULT_OPTIONS, ...options };
  }

  /**
   * Lazily instantiate the `InternalConvexClient` so we don't create the WebSocket
   * when server-side rendering.
   */
  private get sync() {
    if (this.closed) {
      throw new Error("ConvexReactClient has already been closed.");
    }
    if (this.cachedSync) {
      return this.cachedSync;
    }
    this.cachedSync = new InternalConvexClient(
      this.clientConfig,
      updatedQueries => this.transition(updatedQueries),
      this.options
    );
    if (this.adminAuth) {
      this.cachedSync.setAdminAuth(this.adminAuth);
    }
    return this.cachedSync;
  }

  /**
   * Set the authentication token to be used for subsequent queries and mutations.
   * Should be called whenever the token changes (i.e. due to expiration and refresh)
   * @param token - JWT-encoded OpenID Connect Identity Token
   */
  setAuth(token: string) {
    this.sync.setAuth(token);
  }

  /**
   * Clear the current authentication token if set.
   */
  clearAuth() {
    this.sync.clearAuth();
  }

  /**
   * @internal
   */
  setAdminAuth(token: string) {
    this.adminAuth = token;
    if (this.closed) {
      throw new Error("ConvexReactClient has already been closed.");
    }
    if (this.cachedSync) {
      this.sync.setAdminAuth(token);
    }
  }

  /**
   * Construct a new {@link Watch} on a Convex query function.
   *
   * **Most application code should not call this method directly. Instead use
   * the `useQuery` hook generated by `npx convex codegen`.**
   *
   * @param name - The name of the query function.
   * @param args - The arguments to the query.
   * @returns The {@link Watch} object.
   */
  watchQuery<Name extends QueryNames<API>>(
    name: Name,
    ...args: Parameters<NamedQuery<API, Name>>
  ): Watch<NamedQuery<API, Name>> {
    return {
      onUpdate: callback => {
        const { queryToken, unsubscribe } = this.sync.subscribe(
          name as string,
          args
        );

        const currentListeners = this.listeners.get(queryToken);
        if (currentListeners !== undefined) {
          currentListeners.add(callback);
        } else {
          this.listeners.set(queryToken, new Set([callback]));
        }

        return () => {
          if (this.closed) {
            return;
          }

          const currentListeners = this.listeners.get(queryToken)!;
          currentListeners.delete(callback);
          if (currentListeners.size == 0) {
            this.listeners.delete(queryToken);
          }
          unsubscribe();
        };
      },

      localQueryResult: () => {
        // Use the cached client because we can't have a query result if we don't
        // even have a client yet!
        if (this.cachedSync) {
          return this.cachedSync.localQueryResult(
            name as string,
            args
          ) as ReturnType<NamedQuery<API, Name>>;
        }
        return undefined;
      },
    };
  }

  /**
   * Construct a new {@link ReactMutation}.
   *
   * @param name - The name of the Mutation.
   * @returns The {@link ReactMutation} object with that name.
   */
  mutation<Name extends MutationNames<API>>(
    name: Name
  ): ReactMutation<API, Name> {
    return createMutation(name, () => this.sync);
  }

  /**
   * Close any network handles associated with this client and stop all subscriptions.
   *
   * Call this method when you're done with a {@link ConvexReactClient} to
   * dispose of its sockets and resources.
   *
   * @returns A `Promise` fulfilled when the connection has been completely closed.
   */
  async close(): Promise<void> {
    this.closed = true;
    // Prevent outstanding React batched updates from invoking listeners.
    this.listeners = new Map();
    if (this.cachedSync) {
      const sync = this.cachedSync;
      this.cachedSync = undefined;
      await sync.close();
    }
  }

  private transition(updatedQueries: QueryToken[]) {
    ReactDOM.unstable_batchedUpdates(() => {
      for (const queryToken of updatedQueries) {
        const callbacks = this.listeners.get(queryToken);
        if (callbacks) {
          for (const callback of callbacks) {
            callback();
          }
        }
      }
    });
  }
}

const ConvexContext = React.createContext<ConvexReactClient<any>>(
  undefined as unknown as ConvexReactClient<any> // in the future this will be a mocked client for testing
);

/**
 * Get the {@link ConvexReactClient} within a React component.
 *
 * This relies on the {@link ConvexProvider} being above in the React component tree.
 *
 * If you're using code generation, use the `useConvex` function in
 * `convex/_generated/react.js` which is typed for your API.
 *
 * @returns The active {@link ConvexReactClient} object, or `undefined`.
 *
 * @public
 */
export function useConvexGeneric<
  API extends GenericAPI
>(): ConvexReactClient<API> {
  return useContext(ConvexContext);
}

/**
 * Provides an active Convex {@link ConvexReactClient} to descendants of this component.
 *
 * Wrap your app in this component to use Convex hooks `useQuery`,
 * `useMutation`, and `useConvex`.
 *
 * @param props - an object with a `client` property that refers to a {@link ConvexReactClient}.
 *
 * @public
 */
export const ConvexProvider: React.FC<{
  client: ConvexReactClient<any>;
  children?: React.ReactNode;
}> = ({ client, children }) => {
  return React.createElement(
    ConvexContext.Provider,
    { value: client },
    children
  );
};

/**
 * Load a reactive query within a React component.
 *
 * This React hook contains internal state that will cause a rerender
 * whenever the query result changes.
 *
 * Throws an error if not used under {@link ConvexProvider}.
 *
 * If you're using code generation, use the `useQuery` function in
 * `convex/_generated/react.js` which is typed for your API.
 *
 * @param name - The name of the query function.
 * @param args - The arguments to the query function.
 * @returns `undefined` if loading and the query's return value otherwise.
 *
 * @public
 */
export function useQueryGeneric<
  API extends GenericAPI,
  Name extends QueryNames<API>
>(
  name: Name,
  ...args: Parameters<NamedQuery<API, Name>>
): ReturnType<NamedQuery<API, Name>> | undefined {
  const convex = useContext(ConvexContext);
  if (convex === undefined) {
    throw new Error(
      "Could not find Convex client! `useQuery` must be used in the React component " +
        "tree under `ConvexProvider`. Did you forget it? " +
        "See https://docs.convex.dev/quick-start#set-up-convex-in-your-react-app"
    );
  }
  const subscription = useMemo(
    () => {
      const watch = convex.watchQuery(name, ...args);
      return {
        getCurrentValue: () => watch.localQueryResult(),
        subscribe: (callback: () => void) => watch.onUpdate(callback),
      };
    },
    // ESLint doesn't like that we're stringifying the args. We do this because
    // we want to avoid recreating the subscription if the args are a different
    // object that serializes to the same result.
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [name, convex, JSON.stringify(convexToJson(args))]
  );

  const queryResult = useSubscription(subscription);
  return queryResult;
}

/**
 * Construct a new {@link ReactMutation}.
 *
 * Mutation objects can be called like functions to request execution of the
 * corresponding Convex function, or further configured with
 * [optimistic updates](https://docs.convex.dev/using/optimistic-updates).
 *
 * The value returned by this hook is stable across renders, so it can be used
 * by React dependency arrays and memoization logic relying on object identity
 * without causing rerenders.
 *
 * If you're using code generation, use the `useMutation` function in
 * `convex/_generated/react.js` which is typed for your API.
 *
 * Throws an error if not used under {@link ConvexProvider}.
 *
 * @param name - The name of the mutation.
 * @returns The {@link ReactMutation} object with that name.
 *
 * @public
 */
export function useMutationGeneric<
  API extends GenericAPI,
  Name extends MutationNames<API>
>(name: Name): ReactMutation<API, Name> {
  const convex = useContext(ConvexContext);
  if (convex === undefined) {
    throw new Error(
      "Could not find Convex client! `useMutation` must be used in the React component " +
        "tree under `ConvexProvider`. Did you forget it? " +
        "See https://docs.convex.dev/quick-start#set-up-convex-in-your-react-app"
    );
  }
  return useMemo(() => convex.mutation(name), [convex, name]);
}

/**
 * Internal type helper used by Convex code generation.
 *
 * Used to give {@link useQueryGeneric} a type specific to your API.
 * @public
 */
export type UseQueryForAPI<API extends GenericAPI> = <
  Name extends QueryNames<API>
>(
  name: Name,
  ...args: Parameters<NamedQuery<API, Name>>
) => ReturnType<NamedQuery<API, Name>> | undefined;

/**
 * Internal type helper used by Convex code generation.
 *
 * Used to give {@link useMutationGeneric} a type specific to your API.
 * @public
 */
export type UseMutationForAPI<API extends GenericAPI> = <
  Name extends MutationNames<API>
>(
  name: Name
) => ReactMutation<API, Name>;

/**
 * Internal type helper used by Convex code generation.
 *
 * Used to give {@link useConvexGeneric} a type specific to your API.
 * @public
 */
export type UseConvexForAPI<API extends GenericAPI> =
  () => ConvexReactClient<API>;
