import onChange from "on-change"

import { DeepstreamClient } from "@deepstream/client"
import { CONNECTION_STATE } from "@deepstream/client/dist/src/constants"
import { Record as DSRecord } from "@deepstream/client/dist/src/record/record"

import * as log from "./log"

import { SubscriptionCallback } from "./validate"
import { UserData, JSONValue, JSONObject, RecordData } from "./validate"
import { isJSONValue, isJSONObject, isEmpty } from "./validate"

import { patchInPlace } from "./patch"

type SharedObject = JSONObject

const sharedRecordLookup = new WeakMap<SharedObject, Record>()

export class Record {
  readonly #ds: DeepstreamClient
  readonly #name: string
  #dsRecord: DSRecord | null
  #shared: SharedObject
  #watchedShared: SharedObject
  #whenLoaded: Promise<void> | null

  constructor(ds: DeepstreamClient, name: string) {
    this.#ds = ds
    this.#name = name
    this.#dsRecord = null
    this.#shared = {}
    this.#watchedShared = onChange(
      this.#shared,
      this.#onClientChangeData.bind(this),
      {
        onValidate: this.#onClientValidateData.bind(this),
      }
    )
    this.#whenLoaded = null

    sharedRecordLookup.set(this.#shared, this)
  }

  async load(initObject?: UserData, overwrite = false): Promise<void> {
    if (this.#whenLoaded) {
      log.warn("Record.load() called twice!", this.#name)
      return this.#whenLoaded
    }

    if (this.#ds.getConnectionState() !== CONNECTION_STATE.OPEN) {
      // "OPEN"
      log.error("Record.load() called before room is connected.", this.#name)
      return
    }

    const innerLoad = async () => {
      this.#dsRecord = this.#ds.record.getRecord(this.#name)
      this.#dsRecord.subscribe(this.#onServerChangeData.bind(this), true)
      await this.#dsRecord.whenReady()
      if (!initObject) return
      await this.initData(initObject, overwrite)
    }

    this.#whenLoaded = innerLoad()
    return this.#whenLoaded
  }

  get whenLoaded(): Promise<void> {
    if (this.#whenLoaded === null) {
      log.error("Record.whenLoaded called before load().", this.#name)
      return Promise.reject(
        new Error("Record.whenLoaded called before load().")
      )
    }
    return this.#whenLoaded
  }

  /**
   * sets initial data on the record only if the record is empty
   *
   * @param data initial data to set on the record
   */
  async initData(data: UserData, overwrite = false): Promise<void> {
    if (!this.#dsRecord?.isReady) {
      log.error("Record.initData() called before record ready.", this.#name)
      return
    }

    // if (!data) return;
    if (!overwrite && !isEmpty(this.#dsRecord.get())) return // don't overwrite existing data
    if (!isJSONObject(data, "init-data")) return // don't try to write bad data

    // todo: allow but warn non-owner writes
    await this.#dsRecord.setWithAck(data)
  }

  setData(data: UserData): void {
    if (!this.#dsRecord?.isReady) {
      // prettier-ignore
      log.error(
        `Record.setData() called before record ready. ${
          this.#name
        }\n Ignored: ${JSON.stringify(data)}`
      );
      return
    }
    if (!isJSONObject(data, "set-data")) return // don't try to write bad data

    // todo: allow but warn non-owner writes
    this.#dsRecord.set(data)
  }

  watchShared(callback: SubscriptionCallback, triggerNow?: boolean): void

  watchShared(
    path: string,
    callback: SubscriptionCallback,
    triggerNow?: boolean
  ): void

  watchShared(
    path: string | SubscriptionCallback,
    cb?: SubscriptionCallback | boolean,
    triggerNow?: boolean
  ): void {
    if (!this.#dsRecord?.isReady) {
      log.warn(`watchShared() called on '${this.#name}' before ready.`)
      return
    }

    // @ts-expect-error subscribe overload signatures DO match watchShared
    this.#dsRecord.subscribe(path, cb, triggerNow)
  }

  get shared(): JSONObject {
    return this.#watchedShared
  }

  get name(): string {
    return this.#name
  }

  async delete() {
    if (!this.#dsRecord?.isReady) {
      log.error(`delete() called on ${this.#name} before ready.`)
      return
    }

    return new Promise((resolve) => {
      this.#dsRecord?.once("delete", resolve)
      this.#dsRecord?.delete()
    })
  }

  async _set(path: string, value: JSONValue) {
    // value `as RecordData` because all JSONValues ARE supported
    await this.#dsRecord?.setWithAck(path, value as RecordData)
  }

  _get(key?: string): JSONValue {
    return this.#dsRecord?.get(key) as JSONValue
  }

  #onClientValidateData(
    path: string,
    newValue: UserData,
    oldValue: UserData
  ): boolean {
    return isJSONValue(newValue, `${this.#name}/${path}`)
  }

  #onClientChangeData(
    path: string,
    newValue: UserData,
    oldValue: UserData
  ): void {
    if (!this.#dsRecord?.isReady) {
      // prettier-ignore
      log.warn(
        `Shared object written to before ready. ${
          this.#name
        }\n Ignored: ${path} = ${JSON.stringify(newValue)}`
      );
      return
    }
    //! experimental
    // function get(obj: JSONValue, path: string) {
    //   return path.split(".").reduce((current, key) => {
    //     return current && current[key];
    //   }, obj);
    // }

    // function propertyIsEnumerable(obj: JSONValue, path: string) {
    //   const keys = path.split(".");
    //   const lastKey = keys.pop();
    //   const parent = keys.reduce((current, key) => {
    //     return current && current[key];
    //   }, obj);
    //   // return parent && parent.hasOwnProperty(lastKey);

    //   return (
    //     parent && Object.prototype.propertyIsEnumerable.call(parent, lastKey)
    //   );
    // }

    // if (!propertyIsEnumerable(this.#shared, path)) {
    //   return; // don't sync non-enumerable properties
    // }
    //! /experimental

    // todo: warn and allow non-owner writes

    // `as JSONValue` because newValue validated in onClientValidateData
    this._set(path, newValue as JSONValue)
  }

  #onServerChangeData(data: JSONObject): void {
    /* istanbul ignore next */ // the server should never be sending this
    if (!isJSONObject(data, "server-data")) {
      log.error(`Incoming server data not valid.`)
    }
    // don't replace #shared itself as #watchedShared has a reference to it
    // instead patch it to match the incoming data
    patchInPlace(this.#shared, data, "shared")
  }

  static recordForShared(watchedShared: JSONObject): Record | undefined {
    const shared = onChange.target(watchedShared)

    if (!sharedRecordLookup.has(shared)) {
      log.error(`No record found for shared object.`)
      return undefined
    }
    return sharedRecordLookup.get(shared)
  }
}
