import { FilterExpr, type Query } from '@uwdata/mosaic-sql';
import { type Coordinator } from './Coordinator.js';
import { type Selection } from './Selection.js';
import { throttle } from './util/throttle.js';

export type ClientQuery = Query | string | null;

export function isMosaicClient(x: unknown): x is MosaicClient {
  return x instanceof MosaicClient;
}

/**
 * A Mosaic client is a data consumer that indicates its data needs to a
 * Mosaic coordinator via the query method. The coordinator is responsible
 * for issuing queries and returning results to the client.
 *
 * The client life-cycle consists of connection to a coordinator,
 * initialization (potentially involving queries for data schema and summary
 * statistic information), and then interactive queries that may be driven by
 * an associated selection. When no longer needed, a client should be
 * disconnected from the coordinator.
 *
 * When enabled, a client will initialize and respond to query update requests.
 * If disabled, the client will delay initialization and not respond to queries
 * until enabled again. Disabling a client can improve system performance when
 * associated interface elements are offscreen or disabled.
 */
export class MosaicClient {
  _filterBy: Selection | undefined;
  _requestUpdate: () => void;
  _coordinator: Coordinator | null;
  _pending: Promise<unknown>;
  _enabled: boolean;
  /**
   * Initialization state. One of `0` (uninitialized), `-1` (preparing),
   * or `1` (initialized).
   */
  _initialized: -1 | 0 | 1;
  _request: Query | boolean | null;

  /**
   * Create a new client instance.
   * @param filterSelection An optional selection to
   *  interactively filter this client's data. If provided, a coordinator
   *  will re-query and update the client when the selection updates.
   */
  constructor(filterSelection?: Selection) {
    this._filterBy = filterSelection;
    this._requestUpdate = throttle(() => this.requestQuery(), true);
    this._coordinator = null;
    this._pending = Promise.resolve();
    this._enabled = true;
    this._initialized = 0;
    this._request = null;
  }

  /**
   * @returns this client's connected coordinator.
   */
  get coordinator(): Coordinator | null {
    return this._coordinator;
  }

  /**
   * Set this client's connected coordinator.
   */
  set coordinator(coordinator: Coordinator | null) {
    this._coordinator = coordinator;
  }

  /**
   * Return this client's enabled state.
   */
  get enabled(): boolean {
    return this._enabled;
  }

  /**
   * Return this client's initialization state:
   * `true` if initialization is complete, `false` otherwise.
   */
  get initialized(): boolean {
    return this._initialized > 0;
  }

  /**
   * Set this client's enabled state;
   */
  set enabled(state: boolean) {
    state = !!state; // ensure boolean
    if (this._enabled !== state) {
      this._enabled = state;
      if (state) {
        if (!this._initialized) {
          // initialization includes a query request
          this.initialize();
        } else if (this._request) {
          // request query now if requested while disabled
          this.requestQuery(this._request === true ? undefined : this._request);
        }
        this._request = null;
      }
    }
  }

  /**
   * Return a Promise that resolves once the client has updated.
   */
  get pending(): Promise<unknown> {
    return this._pending;
  }

  /**
   * @returns this client's filter selection.
   */
  get filterBy(): Selection | undefined {
    return this._filterBy;
  }

  /**
   * Return a boolean indicating if the client query can be sped up with
   * materialized views of pre-aggregated data. Should return true if changes
   * to the filterBy selection do not change the groupby domain of the client
   * query.
   */
  get filterStable(): boolean {
    return true;
  }

  /**
   * Prepare the client before the `query()` method is called. Subclasses
   * should override this method as needed, potentially issuing one or more
   * queries to gather data or metadata needed prior to `query` calls.
   */
  async prepare(): Promise<void> {
  }

  /**
   * Return a query specifying the data needed by this client.
   * @param filter The filtering criteria to apply in the query.
   * @returns The client query
   */
  query(filter?: FilterExpr | null): ClientQuery { // eslint-disable-line @typescript-eslint/no-unused-vars
    return null;
  }

  /**
   * Called by the coordinator to inform the client that a query is pending.
   * @returns this
   */
  queryPending(): this {
    return this;
  }

  /**
   * Called by the coordinator to return a query result.
   * @param data The query result.
   * @returns this
   */
  queryResult(data: unknown): this { // eslint-disable-line @typescript-eslint/no-unused-vars
    return this;
  }

  /**
   * Called by the coordinator to report a query execution error.
   * @param error
   * @returns this
   */
  queryError(error: Error): this { // eslint-disable-line @typescript-eslint/no-unused-vars
    // do nothing, the coordinator logs the error
    return this;
  }

  /**
   * Request the coordinator to execute a query for this client.
   * If an explicit query is not provided, the client `query` method will
   * be called, filtered by the current `filterBy` selection. This method has
   * no effect if the client is not connected to a coordinator. If the client
   * is connected by currently disabled, the request will be serviced if the
   * client is later enabled.
   * @param query The query to request. If unspecified, the query
   *  will be determined by the client's `query` method and the current
   *  `filterBy` selection state.
   */
  requestQuery(query?: Query): Promise<unknown> | null {
    if (this._enabled) {
      const q = query || this.query(this.filterBy?.predicate(this));
      return this._coordinator!.requestQuery(this, q);
    } else {
      this._request = query ?? true;
      return null;
    }
  }

  /**
   * Request that the coordinator perform a throttled update of this client
   * using the default query. Unlike requestQuery, for which every call results
   * in an executed query, multiple calls to requestUpdate may be consolidated
   * into a single update. This method has no effect if the client is not
   * connected to a coordinator. If the client is connected but currently
   * disabled, the request will be serviced if the client is later enabled.
   */
  requestUpdate(): void {
    if (this._enabled) {
      this._requestUpdate();
    } else {
      this.requestQuery();
    }
  }

  /**
   * Reset this client, calling the prepare method and query requests. This
   * method has no effect if the client is not registered with a coordinator.
   */
  initialize(): void {
    if (!this._enabled) {
      // clear flag so we initialize when enabled again
      this._initialized = 0;
    } else if (this._coordinator) {
      // if connected, let's initialize
      this._initialized = -1;
      this._pending = this.prepare().then(() => {
        this._initialized = 1;
        return this.requestQuery();
      });
    }
  }

  /**
   * Remove this client: disconnect from the coordinator and free up any
   * resource use. This method has no effect if the client is not connected
   * to a coordinator.
   *
   * If overriding this method in a client subclass, be sure to also
   * disconnect from the coordinator.
   */
  destroy(): void {
    this._enabled = false;
    this.coordinator?.disconnect(this);
  }

  /**
   * Requests a client update, for example to (re-)render an interface
   * component.
   */
  update(): this | Promise<unknown> {
    return this;
  }
}