import { validateDeploymentUrl } from "../common/index.js";
import {
  BaseConvexClient,
  BaseConvexClientOptions,
  QueryToken,
  UserIdentityAttributes,
} from "./index.js";
import {
  FunctionArgs,
  FunctionReference,
  FunctionReturnType,
} from "../server/index.js";
import { getFunctionName } from "../server/api.js";
import { AuthTokenFetcher } from "./sync/authentication_manager.js";

// In Node.js builds this points to a bundled WebSocket implementation. If no
// WebSocket implementation is manually specified or globally available,
// this one is used.
let defaultWebSocketConstructor: typeof WebSocket | undefined;

/** internal */
export function setDefaultWebSocketConstructor(ws: typeof WebSocket) {
  defaultWebSocketConstructor = ws;
}

export type ConvexClientOptions = BaseConvexClientOptions & {
  /**
   * `disabled` makes onUpdate callback registration a no-op and actions,
   * mutations and one-shot queries throw. Setting disabled to true may be
   * useful for server-side rendering, where subscriptions don't make sense.
   */
  disabled?: boolean;
  /**
   * Whether to prompt users in browsers about queued or in-flight mutations.
   * This only works in environments where `window.onbeforeunload` is available.
   *
   * Defaults to true when `window` is defined, otherwise false.
   */
  unsavedChangesWarning?: boolean;
};

/**
 * Stops callbacks from running.
 *
 * @public
 */
export type Unsubscribe<T> = {
  /** Stop calling callback when query results changes. If this is the last listener on this query, stop received updates. */
  (): void;
  /** Stop calling callback when query results changes. If this is the last listener on this query, stop received updates. */
  unsubscribe(): void;
  /** Get the last known value, possibly with local optimistic updates applied. */
  getCurrentValue(): T | undefined;
  /** @internal */
  getQueryLogs(): string[] | undefined;
};

/**
 * Subscribes to Convex query functions and executes mutations and actions over a WebSocket.
 *
 * Optimistic updates for mutations are not provided for this client.
 * Third party clients may choose to wrap {@link browser.BaseConvexClient} for additional control.
 *
 * ```ts
 * const client = new ConvexClient("https://happy-otter-123.convex.cloud");
 * const unsubscribe = client.onUpdate(api.messages.list, (messages) => {
 *   console.log(messages[0].body);
 * });
 * ```
 *
 * @public
 */
export class ConvexClient {
  private listeners: Set<QueryInfo>;
  private _client: BaseConvexClient | undefined;
  // A synthetic server event to run callbacks the first time
  private callNewListenersWithCurrentValuesTimer:
    | ReturnType<typeof setTimeout>
    | undefined;
  private _closed: boolean;
  disabled: boolean;
  /**
   * Once closed no registered callbacks will fire again.
   */
  get closed(): boolean {
    return this._closed;
  }
  get client(): BaseConvexClient {
    if (this._client) return this._client;
    throw new Error("ConvexClient is disabled");
  }

  /**
   * Construct a client and immediately initiate a WebSocket connection to the passed address.
   *
   * @public
   */
  constructor(address: string, options: ConvexClientOptions = {}) {
    if (options.skipConvexDeploymentUrlCheck !== true) {
      validateDeploymentUrl(address);
    }
    const { disabled, ...baseOptions } = options;
    this._closed = false;
    this.disabled = !!disabled;
    if (
      defaultWebSocketConstructor &&
      !("webSocketConstructor" in baseOptions) &&
      typeof WebSocket === "undefined"
    ) {
      baseOptions.webSocketConstructor = defaultWebSocketConstructor;
    }
    if (
      typeof window === "undefined" &&
      !("unsavedChangesWarning" in baseOptions)
    ) {
      baseOptions.unsavedChangesWarning = false;
    }
    if (!this.disabled) {
      this._client = new BaseConvexClient(
        address,
        (updatedQueries) => this._transition(updatedQueries),
        baseOptions,
      );
    }
    this.listeners = new Set();
  }

  /**
   * Call a callback whenever a new result for a query is received. The callback
   * will run soon after being registered if a result for the query is already
   * in memory.
   *
   * The return value is an {@link Unsubscribe} object which is both a function
   * an an object with properties. Both of the patterns below work with this object:
   *
   *```ts
   * // call the return value as a function
   * const unsubscribe = client.onUpdate(api.messages.list, {}, (messages) => {
   *   console.log(messages);
   * });
   * unsubscribe();
   *
   * // unpack the return value into its properties
   * const {
   *   getCurrentValue,
   *   unsubscribe,
   * } = client.onUpdate(api.messages.list, {}, (messages) => {
   *   console.log(messages);
   * });
   *```
   *
   * @param query - A {@link server.FunctionReference} for the public query to run.
   * @param args - The arguments to run the query with.
   * @param callback - Function to call when the query result updates.
   * @param onError - Function to call when the query result updates with an error.
   * If not provided, errors will be thrown instead of calling the callback.
   *
   * @return an {@link Unsubscribe} function to stop calling the onUpdate function.
   */
  onUpdate<Query extends FunctionReference<"query">>(
    query: Query,
    args: FunctionArgs<Query>,
    callback: (result: FunctionReturnType<Query>) => unknown,
    onError?: (e: Error) => unknown,
  ): Unsubscribe<Query["_returnType"]> {
    if (this.disabled) {
      const disabledUnsubscribe = (() => {}) as Unsubscribe<
        Query["_returnType"]
      >;
      const unsubscribeProps: RemoveCallSignature<
        Unsubscribe<Query["_returnType"]>
      > = {
        unsubscribe: disabledUnsubscribe,
        getCurrentValue: () => undefined,
        getQueryLogs: () => undefined,
      };
      Object.assign(disabledUnsubscribe, unsubscribeProps);
      return disabledUnsubscribe;
    }

    // BaseConvexClient takes care of deduplicating queries subscriptions...
    const { queryToken, unsubscribe } = this.client.subscribe(
      getFunctionName(query),
      args,
    );

    // ...but we still need to bookkeep callbacks to actually call them.
    const queryInfo: QueryInfo = {
      queryToken,
      callback,
      onError,
      unsubscribe,
      hasEverRun: false,
      query,
      args,
    };
    this.listeners.add(queryInfo);

    // If the callback is registered for a query with a result immediately available
    // schedule a fake transition to call the callback soon instead of waiting for
    // a new server update (which could take seconds or days).
    if (
      this.queryResultReady(queryToken) &&
      this.callNewListenersWithCurrentValuesTimer === undefined
    ) {
      this.callNewListenersWithCurrentValuesTimer = setTimeout(
        () => this.callNewListenersWithCurrentValues(),
        0,
      );
    }

    const unsubscribeProps: RemoveCallSignature<
      Unsubscribe<Query["_returnType"]>
    > = {
      unsubscribe: () => {
        if (this.closed) {
          // all unsubscribes already ran
          return;
        }
        this.listeners.delete(queryInfo);
        unsubscribe();
      },
      getCurrentValue: () => this.client.localQueryResultByToken(queryToken),
      getQueryLogs: () => this.client.localQueryLogs(queryToken),
    };
    const ret = unsubscribeProps.unsubscribe as Unsubscribe<
      Query["_returnType"]
    >;
    Object.assign(ret, unsubscribeProps);
    return ret;
  }

  // Run all callbacks that have never been run before if they have a query
  // result available now.
  private callNewListenersWithCurrentValues() {
    this.callNewListenersWithCurrentValuesTimer = undefined;
    this._transition([], true);
  }

  private queryResultReady(queryToken: QueryToken): boolean {
    return this.client.hasLocalQueryResultByToken(queryToken);
  }

  async close() {
    if (this.disabled) return;
    // prevent pending updates
    this.listeners.clear();
    this._closed = true;
    return this.client.close();
  }

  /**
   * Set the authentication token to be used for subsequent queries and mutations.
   * `fetchToken` will be called automatically again if a token expires.
   * `fetchToken` should return `null` if the token cannot be retrieved, for example
   * when the user's rights were permanently revoked.
   * @param fetchToken - an async function returning the JWT-encoded OpenID Connect Identity Token
   * @param onChange - a callback that will be called when the authentication status changes
   */
  setAuth(
    fetchToken: AuthTokenFetcher,
    onChange?: (isAuthenticated: boolean) => void,
  ) {
    this.client.setAuth(
      fetchToken,
      onChange ??
        (() => {
          // Do nothing
        }),
    );
  }

  /**
   * @internal
   */
  setAdminAuth(token: string, identity?: UserIdentityAttributes) {
    if (this.closed) {
      throw new Error("ConvexClient has already been closed.");
    }
    if (this.disabled) return;
    this.client.setAdminAuth(token, identity);
  }

  /**
   * @internal
   */
  _transition(updatedQueries: QueryToken[], callNewListeners = false) {
    // Deduping subscriptions happens in the BaseConvexClient, so not much to do here.

    // Call all callbacks in the order they were registered
    for (const queryInfo of this.listeners) {
      const { callback, queryToken, onError, hasEverRun } = queryInfo;
      if (
        updatedQueries.includes(queryToken) ||
        (callNewListeners &&
          !hasEverRun &&
          this.client.hasLocalQueryResultByToken(queryToken))
      ) {
        queryInfo.hasEverRun = true;
        let newValue;
        try {
          newValue = this.client.localQueryResultByToken(queryToken);
        } catch (error) {
          if (!(error instanceof Error)) throw error;
          if (onError) {
            onError(
              error,
              "Second argument to onUpdate onError is reserved for later use",
            );
          } else {
            // Make some noise without unsubscribing or failing to call other callbacks.
            void Promise.reject(error);
          }
          continue;
        }
        callback(
          newValue,
          "Second argument to onUpdate callback is reserved for later use",
        );
      }
    }
  }

  /**
   * Execute a mutation function.
   *
   * @param mutation - A {@link server.FunctionReference} for the public mutation
   * to run.
   * @param args - An arguments object for the mutation.
   * @param options - A {@link MutationOptions} options object for the mutation.
   * @returns A promise of the mutation's result.
   */
  async mutation<Mutation extends FunctionReference<"mutation">>(
    mutation: Mutation,
    args: FunctionArgs<Mutation>,
  ): Promise<Awaited<FunctionReturnType<Mutation>>> {
    if (this.disabled) throw new Error("ConvexClient is disabled");
    return await this.client.mutation(getFunctionName(mutation), args);
  }

  /**
   * Execute an action function.
   *
   * @param action - A {@link server.FunctionReference} for the public action
   * to run.
   * @param args - An arguments object for the action.
   * @returns A promise of the action's result.
   */
  async action<Action extends FunctionReference<"action">>(
    action: Action,
    args: FunctionArgs<Action>,
  ): Promise<Awaited<FunctionReturnType<Action>>> {
    if (this.disabled) throw new Error("ConvexClient is disabled");
    return await this.client.action(getFunctionName(action), args);
  }

  /**
   * Fetch a query result once.
   *
   * @param query - A {@link server.FunctionReference} for the public query
   * to run.
   * @param args - An arguments object for the query.
   * @returns A promise of the query's result.
   */
  async query<Query extends FunctionReference<"query">>(
    query: Query,
    args: Query["_args"],
  ): Promise<Awaited<Query["_returnType"]>> {
    if (this.disabled) throw new Error("ConvexClient is disabled");
    const value = this.client.localQueryResult(getFunctionName(query), args) as
      | Awaited<Query["_returnType"]>
      | undefined;
    if (value !== undefined) return Promise.resolve(value);

    return new Promise((resolve, reject) => {
      const { unsubscribe } = this.onUpdate(
        query,
        args,
        (value) => {
          unsubscribe();
          resolve(value);
        },
        (e: Error) => {
          unsubscribe();
          reject(e);
        },
      );
    });
  }
}

// internal information tracked about each registered callback
type QueryInfo = {
  callback: (result: any, meta: unknown) => unknown;
  onError: ((e: Error, meta: unknown) => unknown) | undefined;
  unsubscribe: () => void;
  queryToken: QueryToken;
  hasEverRun: boolean;
  // query and args are just here for debugging, the queryToken is authoritative
  query: FunctionReference<"query">;
  args: any;
};

type RemoveCallSignature<T> = Omit<T, never>;
