import { version } from "../../index.js";
import { Value } from "../../values/index.js";
import { LocalSyncState } from "./local_state.js";
import { MutationManager } from "./mutation_manager.js";
import {
  OptimisticQueryResults,
  QueryResultsMap,
} from "./optimistic_updates_impl.js";
import {
  ActionId,
  ServerMessage,
  QueryId,
  MutationId,
  QueryJournal,
} from "./protocol.js";
import { QueryResult, RemoteQuerySet } from "./remote_query_set.js";
import { QueryToken, serializePathAndArgs } from "./udf_path_utils.js";
import { ReconnectMetadata, WebSocketManager } from "./web_socket_manager.js";
import { v4 as uuidv4 } from "uuid";
import { GenericAPI } from "../../api/index.js";
import { logFatalError } from "../logging.js";
import { ClientConfiguration } from "../client_config.js";
import { ActionManager } from "./action_manager.js";
import {
  OptimisticLocalStore,
  OptimisticUpdate,
} from "./optimistic_updates.js";

/**
 * Options for {@link InternalConvexClient}.
 *
 * @public
 */
export type ClientOptions = {
  /**
   * 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;
};

/**
 * State describing the client's connection with the Convex backend.
 *
 * @public
 */
export type ConnectionState = {
  hasInflightMutation: boolean;
  hasInflightAction: boolean;
  isWebSocketConnected: boolean;
};

const DEFAULT_OPTIONS = {
  unsavedChangesWarning: true,
};

/**
 * Low-level client for directly integrating state management libraries
 * with Convex.
 *
 * Most developers should use higher level clients, like
 * the {@link ConvexHttpClient} or the React hook based {@link react.ConvexReactClient}.
 *
 * @public
 */
export class InternalConvexClient {
  private readonly state: LocalSyncState;
  private readonly mutationManager: MutationManager;
  private readonly actionManager: ActionManager;
  private readonly webSocketManager: WebSocketManager;
  private remoteQuerySet: RemoteQuerySet;
  private readonly optimisticQueryResults: OptimisticQueryResults;
  private readonly onTransition: (updatedQueries: QueryToken[]) => void;
  private nextMutationId: MutationId;
  private nextActionId: ActionId;
  private readonly sessionId: string;

  /**
   * @param clientConfig - The generated client configuration for your project.
   * You can find this at `convex/_generated/clientConfig.js`.
   * @param onTransition - A callback receiving an array of query tokens
   * corresponding to query results that have changed.
   * @param options - See {@link ClientOptions} for a full description.
   */
  constructor(
    clientConfig: ClientConfiguration,
    onTransition: (updatedQueries: QueryToken[]) => void,
    options?: ClientOptions
  ) {
    options = { ...DEFAULT_OPTIONS, ...options };
    let webSocketConstructor = options.webSocketConstructor;
    if (!webSocketConstructor && typeof WebSocket === "undefined") {
      throw new Error(
        "No WebSocket global variable defined! To use Convex in an environment without WebSocket try the HTTP client: https://docs.convex.dev/api/classes/browser.ConvexHttpClient"
      );
    }
    webSocketConstructor = webSocketConstructor || WebSocket;

    // Substitute http(s) with ws(s)
    const address = clientConfig.address;
    const i = address.search("://");
    if (i == -1) {
      throw new Error("Provided address was not an absolute URL.");
    }
    const origin = address.substring(i + 3); // move past the double slash
    const protocol = address.substring(0, i);
    let wsProtocol;
    if (protocol === "http") {
      wsProtocol = "ws";
    } else if (protocol === "https") {
      wsProtocol = "wss";
    } else {
      throw new Error(`Unknown parent protocol ${protocol}`);
    }
    const wsUri = `${wsProtocol}://${origin}/api/${version}/sync`;

    this.state = new LocalSyncState();
    this.remoteQuerySet = new RemoteQuerySet(queryId =>
      this.state.queryPath(queryId)
    );
    this.mutationManager = new MutationManager();
    this.actionManager = new ActionManager();
    this.optimisticQueryResults = new OptimisticQueryResults();
    this.onTransition = onTransition;
    this.nextMutationId = 0;
    this.nextActionId = 0;
    this.sessionId = uuidv4();

    const { unsavedChangesWarning } = options;
    if (typeof window === "undefined" && unsavedChangesWarning) {
      throw new Error(
        "unsavedChangesWarning enabled, but no window object found! Navigating away from the page could cause in-flight mutations to be dropped. Pass {unsavedChangesWarning: false} in Convex client options to disable this feature."
      );
    }

    // Listen for tab close events and notify the user on unsaved changes.
    unsavedChangesWarning &&
      window.addEventListener("beforeunload", e => {
        if (
          this.mutationManager.hasUncommittedMutations() ||
          this.actionManager.hasInflightActions()
        ) {
          // There are 3 different ways to trigger this pop up so just try all of
          // them.

          e.preventDefault();
          // This confirmation message doesn't actually appear in most modern
          // browsers but we tried.
          const confirmationMessage =
            "Are you sure you want to leave? Your changes may not be saved.";
          (e || window.event).returnValue = confirmationMessage;
          return confirmationMessage;
        }
      });

    this.webSocketManager = new WebSocketManager(
      wsUri,
      (reconnectMetadata: ReconnectMetadata) => {
        // We have a new WebSocket!

        this.webSocketManager.sendMessage({
          ...reconnectMetadata,
          type: "Connect",
          sessionId: this.sessionId,
        });

        // Throw out our remote query, reissue queries
        // and outstanding mutations, and reauthenticate.
        this.remoteQuerySet = new RemoteQuerySet(queryId =>
          this.state.queryPath(queryId)
        );
        const [querySetModification, authModification] = this.state.restart();
        if (authModification) {
          this.webSocketManager.sendMessage(authModification);
        }
        this.webSocketManager.sendMessage(querySetModification);
        this.actionManager.restart();
        for (const message of this.mutationManager.restart()) {
          this.webSocketManager.sendMessage(message);
        }
      },
      (serverMessage: ServerMessage) => {
        if (serverMessage.type == "Transition") {
          this.remoteQuerySet.transition(serverMessage);
          this.state.saveQueryJournals(serverMessage);
          const completedMutations =
            this.mutationManager.removeCompletedMutations(
              this.remoteQuerySet.timestamp()
            );
          this.notifyOnQueryResultChanges(completedMutations);
        } else if (serverMessage.type == "MutationResponse") {
          const completedMutationId =
            this.mutationManager.onResponse(serverMessage);
          if (completedMutationId) {
            this.notifyOnQueryResultChanges(new Set([completedMutationId]));
          }
        } else if (serverMessage.type == "ActionResponse") {
          this.actionManager.onResponse(serverMessage);
        } else if (serverMessage.type == "FatalError") {
          const error = logFatalError(serverMessage.error);
          void this.webSocketManager.stop();
          throw error;
        }
      },
      webSocketConstructor
    );
  }

  /**
   * Compute the current query results based on the remoteQuerySet and the
   * current optimistic updates and call `onTransition` for all the changed
   * queries.
   *
   * @param completedMutations - A set of mutation IDs whose optimistic updates
   * are no longer needed.
   */
  private notifyOnQueryResultChanges(completedMutations: Set<MutationId>) {
    const remoteQueryResults: Map<QueryId, QueryResult> =
      this.remoteQuerySet.remoteQueryResults();
    const queryTokenToValue: QueryResultsMap = new Map();
    for (const [queryId, result] of remoteQueryResults) {
      const queryToken = this.state.queryToken(queryId);
      // It's possible that we've already unsubscribed to this query but
      // the server hasn't learned about that yet. If so, ignore this one.

      if (queryToken !== null) {
        const query = {
          result,
          udfPath: this.state.queryPath(queryId)!,
          args: this.state.queryArgs(queryId)!,
        };
        queryTokenToValue.set(queryToken, query);
      }
    }

    this.onTransition(
      this.optimisticQueryResults.ingestQueryResultsFromServer(
        queryTokenToValue,
        completedMutations
      )
    );
  }

  setAuth(value: string) {
    const message = this.state.setAuth(value);
    this.webSocketManager.sendMessage(message);
  }

  /** @internal */
  setAdminAuth(value: string) {
    const message = this.state.setAdminAuth(value);
    this.webSocketManager.sendMessage(message);
  }

  clearAuth() {
    const message = this.state.clearAuth();
    this.webSocketManager.sendMessage(message);
  }

  /**
   * Subscribe to a query function.
   *
   * Whenever this query's result changes, the `onTransition` callback
   * passed into the constructor will be called.
   *
   * @param name - The name of the query.
   * @param args - An array of the arguments to the query.
   * @param journal - An (optional) journal produced from a previous
   * execution of this query function. Note that if this query function with
   * these arguments has already been requested the journal will have no effect.
   * @returns An object containing a {@link QueryToken} corresponding to this
   * query and an `unsubscribe` callback.
   */
  subscribe(
    name: string,
    args: any[],
    journal?: QueryJournal
  ): { queryToken: QueryToken; unsubscribe: () => void } {
    // `subscribe` used to collect the arguments with a rest operator
    // (like `...args`). Double check that it's an array to make sure developers
    // have updated their code.
    if (!Array.isArray(args)) {
      throw new Error(
        `Query arguments to \`InternalConvexClient.subcribe\` must be an array. Received ${args}.`
      );
    }

    const { modification, queryToken, unsubscribe } = this.state.subscribe(
      name,
      args,
      journal
    );
    if (modification !== null) {
      this.webSocketManager.sendMessage(modification);
    }
    // TODO: Use FinalizationRegistry?
    return {
      queryToken,
      unsubscribe: () => {
        const modification = unsubscribe();
        if (modification) {
          this.webSocketManager.sendMessage(modification);
        }
      },
    };
  }

  /**
   * A query result based only on the current, local state.
   *
   * The only way this will return a value is if we're already subscribed to the
   * query or its value has been set optimistically.
   */
  localQueryResult(udfPath: string, args: any[]): Value | undefined {
    const queryToken = serializePathAndArgs(udfPath, args);
    return this.optimisticQueryResults.queryResult(queryToken);
  }

  /**
   * Retrieve the current {@link QueryJournal} for this query function.
   *
   * If we have not yet received a result for this query, this will be `undefined`.
   *
   * @param name - The name of the query.
   * @param args - An array of arguments to this query.
   * @returns The query's {@link QueryJournal} or `undefined`.
   */
  queryJournal(name: string, args: any[]): QueryJournal | undefined {
    const queryToken = serializePathAndArgs(name, args);
    return this.state.queryJournal(queryToken);
  }

  /**
   * Get the current {@link ConnectionState} between the client and the Convex
   * backend.
   *
   * @returns The {@link ConnectionState} with the Convex backend.
   */
  connectionState(): ConnectionState {
    return {
      hasInflightMutation: this.mutationManager.hasInflightMutation(),
      hasInflightAction: this.actionManager.hasInflightActions(),
      isWebSocketConnected: this.webSocketManager.socketState() === "ready",
    };
  }

  async mutate<Args extends any[]>(
    udfPath: string,
    args: Args,
    optimisticUpdate: OptimisticUpdate<GenericAPI, Args> | null = null
  ): Promise<any> {
    const mutationId = this.nextMutationId;
    this.nextMutationId++;

    if (optimisticUpdate !== null) {
      const wrappedUpdate = (localQueryStore: OptimisticLocalStore) => {
        optimisticUpdate(localQueryStore, ...args);
      };
      const changedQueries = this.optimisticQueryResults.applyOptimisticUpdate(
        wrappedUpdate,
        mutationId
      );
      this.onTransition(changedQueries);
    }

    const { message, result } = this.mutationManager.request(
      udfPath,
      args,
      mutationId
    );
    this.webSocketManager.sendMessage(message);
    return result;
  }

  async action<Args extends any[]>(udfPath: string, args: Args): Promise<any> {
    const actionId = this.nextActionId;
    this.nextActionId++;

    const { message, result } = this.actionManager.request(
      udfPath,
      args,
      actionId
    );
    this.webSocketManager.sendMessage(message);
    return result;
  }

  async close(): Promise<void> {
    return this.webSocketManager.stop();
  }
}
