import assert from 'node:assert'

import { Transaction } from '@google-cloud/firestore'
import S from '@pbvision/schema'
import stableStringify from 'fast-json-stable-stringify'

import { Data } from './data.js'
import {
  InvalidFieldError,
  InvalidParameterError,
  GenericModelError
} from './errors.js'
import { __Field, SCHEMA_TYPE_TO_FIELD_CLASS_MAP } from './fields.js'
import { Key } from './key.js'
import {
  validateValue,
  SCHEMA_TYPE_TO_JS_TYPE_MAP
} from './utils.js'

/**
 * The base class for modeling data.
 */
export class Model {
  /**
   * Create a representation of a database Doc. Should only be used by the
   * library.
   */
  constructor (isNew, vals, isForUpdateAndMayBePartial = false, isSet = false) {
    this.constructor.__doOneTimeModelPrep()
    assert.ok(typeof isNew === 'boolean', 'isNew must be a boolean')
    assert.ok(typeof isForUpdateAndMayBePartial === 'boolean',
      'isForUpdateAndMayBePartial must be a boolean')
    this.isNew = isNew
    this.__isPartial = isForUpdateAndMayBePartial
    this.__isSet = isSet
    assert.ok(!isSet || !isForUpdateAndMayBePartial,
      'may not be partial when using isSet')

    // __cached_attrs has a __Field subclass object for each non-key attribute.
    this.__cached_attrs = {}

    // __cached_attrs has a __Field subclass object for each non-key attribute.
    this.__attr_getters = {}

    // pull out the Key for this doc
    let keyComponents
    if (vals.__id !== undefined) {
      keyComponents = this.constructor.__decodeCompoundValue(
        this.constructor.__keyOrder, vals.__id)
      delete vals._id
      Object.assign(vals, keyComponents)
      this.__key = this.constructor.key(keyComponents, false)
    } else {
      this.__key = this.constructor.key(vals, true)
    }

    // add user-defined fields from FIELDS & key components from KEY
    for (const [name, opts] of Object.entries(this.constructor._attrs)) {
      this.__addField(name, opts, vals)
    }

    Object.seal(this)
  }

  /** Returns a query object for querying this model. */
  static makeQuery () {
    return Key.firestoreDB.collection(this.collectionName)
  }

  /**
   * Runs a query object returned by this model's makeQuery() function.
   * @returns {Array<Model>} returns an array of instances of this class that
   *   matched the query
   */
  static async runQuery (query) {
    const results = await query.get()
    const ret = []
    results.forEach(raw => {
      const vals = raw.data()
      vals.__id = raw.id
      ret.push(new this(false, vals))
    })
    return ret
  }

  static async register (registrar) {
    this.__doOneTimeModelPrep()
    await registrar.registerModel(this)
  }

  /**
   * Hook for finalizing a model before writing to database
   */
  async finalize () {
  }

  __addField (name, opts, vals) {
    const valSpecified = Object.hasOwnProperty.call(vals, name)
    const getCachedField = () => {
      if (this.__cached_attrs[name]) {
        return this.__cached_attrs[name]
      }
      const Cls = SCHEMA_TYPE_TO_FIELD_CLASS_MAP[opts.schema.type]
      // can't force validation of undefined values for blind updates because
      //   they are permitted to omit fields
      const field = new Cls({
        name,
        opts,
        val: vals[name],
        valIsFromDB: !this.isNew && !this.__isPartial,
        valSpecified,
        isForUpdate: this.__isPartial
      })
      Object.seal(field)
      this.__cached_attrs[name] = field
      return field
    }
    this.__attr_getters[name] = getCachedField
    if (this.isNew || (this.__isPartial && valSpecified)) {
      getCachedField() // create the field now to trigger validation
    }
    Object.defineProperty(this, name, {
      get: () => {
        const field = getCachedField()
        return field.get()
      },
      set: (val) => {
        const field = getCachedField()
        field.set(val)
      }
    })
  }

  static __getFields () {
    return this.FIELDS
  }

  static __validatedSchema () {
    if (Object.constructor.hasOwnProperty.call(this, '__CACHED_SCHEMA')) {
      return this.__CACHED_SCHEMA
    }

    if (!this.KEY) {
      throw new InvalidFieldError('KEY', 'the partition key is required')
    }
    if (this.KEY.isSchema || this.KEY.schema) {
      throw new InvalidFieldError('KEY', 'must define key component name(s)')
    }
    if (Object.keys(this.KEY).length === 0) {
      throw new InvalidFieldError('KEY', '/at least one partition key field/')
    }

    // cannot use the names of non-static Model members (only need to list
    // those that are defined by the constructor; those which are on the
    // prototype are enforced automatically)
    const reservedNames = new Set(['isNew'])
    const proto = this.prototype
    const ret = {}
    for (const schema of [this.KEY, this.__getFields()]) {
      for (const [key, val] of Object.entries(schema)) {
        if (ret[key]) {
          throw new InvalidFieldError(
            key, 'property name cannot be used more than once')
        }
        if (reservedNames.has(key)) {
          throw new InvalidFieldError(
            key, 'field name is reserved and may not be used')
        }
        if (key in proto) {
          throw new InvalidFieldError(key, 'shadows a property name')
        }
        ret[key] = val
      }
    }
    this.__CACHED_SCHEMA = S.obj(ret)
    return this.__CACHED_SCHEMA
  }

  static get schema () {
    return this.__validatedSchema()
  }

  static get __keyOrder () {
    if (Object.constructor.hasOwnProperty.call(this, '__CACHED_KEY_ORDER')) {
      return this.__CACHED_KEY_ORDER
    }
    this.__validatedSchema() // use side effect to validate schema
    this.__CACHED_KEY_ORDER = Object.keys(this.KEY).sort()
    return this.__CACHED_KEY_ORDER
  }

  static __validateCollectionName () {
    const collectionName = this.collectionName
    try {
      assert.ok(!collectionName.endsWith('Model'), 'not include "Model"')
      assert.ok(!collectionName.endsWith('Table'), 'not include "Table"')
      assert.ok(!collectionName.endsWith('Collection'), 'not include "Collection"')
      assert.ok(collectionName.indexOf('_') < 0, 'not include underscores')
      assert.ok(collectionName[0].match(/[A-Z]/), 'start with a capitalized letter')
      assert.ok(collectionName.match(/[a-zA-Z0-9]*/), 'only use letters or numbers')
    } catch (e) {
      throw new Error(`Bad collection name "${collectionName}": it must ${e.message}`)
    }
  }

  /**
   * Check that field names don't overlap, etc.
   * @package
   */
  static __doOneTimeModelPrep () {
    // need to check hasOwnProperty because we don't want to access this
    // property via inheritance (i.e., our parent may have been setup, but
    // the subclass must do its own setup)
    if (Object.hasOwnProperty.call(this, '__setupDone')) {
      return // one-time setup already done
    }
    this.__setupDone = true

    this.__validateCollectionName()
    // _attrs maps the name of attributes that are visible to users of
    // this model. This is the combination of attributes (keys) defined by KEY
    // and FIELDS.
    this._attrs = {}
    this.__KEY_COMPONENT_NAMES = new Set()
    const partitionKeys = new Set(this.__keyOrder)
    for (const [fieldName, schema] of Object.entries(this.schema.objectSchemas)) {
      const isKey = partitionKeys.has(fieldName)
      const finalFieldOpts = __Field.__validateFieldOptions(
        this.collectionName, isKey, fieldName, schema)
      this._attrs[fieldName] = finalFieldOpts
      if (isKey) {
        this.__KEY_COMPONENT_NAMES.add(fieldName)
      }
    }
  }

  /**
   * Defines the key. Every doc in the database is uniquely identified by its'
   * key. The default key is a UUIDv4.
   *
   * A key can simply be some scalar value:
   *   static KEY = { id: S.str }
   *
   * A key may can be "compound key", i.e., a key with one or components, each
   * with their own name and schema:
   *   static KEY = {
   *     email: S.str,
   *     birthYear: S.int.min(1900)
   *   }
   */
  static KEY = { id: S.SCHEMAS.UUID }

  /**
   * Defines the non-key fields. By default there are no fields.
   *
   * Properties are defined as a map from field names to Schema objects:
   * @example
   *   static FIELDS = {
   *     someNumber: S.double,
   *     someNumberWithOptions: S.double.optional().default(0).readOnly()
   *   }
   */
  static FIELDS = {}

  get _id () {
    return this.constructor.__encodeCompoundValue(
      this.constructor.__keyOrder,
      new Proxy(this, {
        get: (target, prop, receiver) => {
          return target.getField(prop).__value
        }
      })
    )
  }

  /**
   * Returns the underlying __Field associated with an attribute.
   *
   * @param {String} name the name of a field from FIELDS
   * @returns {BooleanField|ArrayField|ObjectField|NumberField|StringField}
   */
  getField (name) {
    assert(!name.startsWith('_'), 'may not access internal computed fields')
    return this.__attr_getters[name]()
  }

  /**
   * This is the name of the collection this model is for. By default, the
   * collection name is the model's class name. However, classes may choose
   * to override this method and provide there own name (e.g., for co-existed
   * models where multiple models have data in one collection).
   *
   * @type {String}
   */
  static get collectionName () {
    return this.name
  }

  /**
   * Given a mapping, split compositeKeys from other model fields. Return a
   * 3-tuple, [encodedKey, keyComponents, modelData].
   *
   * @param {Object} data data to be split
   * @package
   */
  static __splitKeysAndData (data) {
    const keyComponents = {}
    const modelData = {}
    Object.keys(data).forEach(key => {
      if (this.__KEY_COMPONENT_NAMES.has(key)) {
        keyComponents[key] = data[key]
      } else if (this._attrs[key]) {
        modelData[key] = data[key]
      } else {
        throw new InvalidParameterError('data', 'unknown field ' + key)
      }
    })
    const _id = this.__encodeCompoundValue(this.__keyOrder, keyComponents)
    return [_id, keyComponents, modelData]
  }

  async __write (ctx) {
    // If ctx is a Transaction object, then its mutator methods like create(),
    // etc.return the Transaction object (for chaining). There's no promise in
    // this case because the updates will be flushed when the transaction
    // commits.
    // If ctx is the Firestore object, then a promise is returned. In that case
    // we await on it here.
    // The return value from this function is always undefined for consistency.
    const ret = this.__writeHelper(ctx)
    if (ret instanceof Transaction) {
      return
    }
    await ret
  }

  __writeHelper (ctx) {
    const docRef = this.__key.docRef
    this.finalize()
    const data = {}
    for (const field of Object.values(this.__cached_attrs)) {
      if (!field.isKey) {
        if (field.hasChangesToCommit(true) || this.isNew) {
          const val = field.__valueForFirestoreWrite()
          if (val !== undefined) {
            data[field.name] = val
          }
        }
      }
    }

    if (this.isNew) {
      // write the entire document from scratch
      if (this.__isSet) {
        // overwrite if it already exists (create if missing)
        return ctx.__dbCtx.set(docRef, data, { merge: false })
      } else {
        // fail if it already exists
        return ctx.__dbCtx.create(docRef, data)
      }
    } else {
      if (!Object.keys(data).length) {
        throw new GenericModelError(
          'update did not provide any data to change',
          this.constructor.collectionName, this.__key.encodedKey)
      }
      return ctx.__dbCtx.update(docRef, data)
    }
  }

  /**
   * Indicates if any field was mutated. New models are considered to be
   * mutated as well.
   * @param {Boolean} expectWrites whether the model will be updated,
   *  default is true.
   * @type {Boolean}
   * @package
   */
  __isMutated (expectWrites = true) {
    if (this.isNew) {
      return true
    }
    for (const field of Object.values(this.__cached_attrs)) {
      if (field.hasChangesToCommit(expectWrites)) {
        // If any field has changes that need to be committed,
        // it will mark the model as mutated.
        return true
      }
    }
    return false
  }

  /**
   * Returns the string representation for the given compound values.
   *
   * This method throws {@link InvalidFieldError} if the compound value does
   * not match the required schema.
   *
   * @param {Array<String>} keyOrder order of keys in the string representation
   * @param {Object} values maps component names to values; may have extra
   *   fields (they will be ignored)
   * @package
   */
  static __encodeCompoundValue (keyOrder, values) {
    const pieces = []
    for (let i = 0; i < keyOrder.length; i++) {
      const fieldName = keyOrder[i]
      const fieldOpts = this._attrs[fieldName]
      const givenValue = values[fieldName]
      if (givenValue === undefined) {
        throw new InvalidFieldError(fieldName, 'must be provided')
      }
      const valueType = validateValue(fieldName, fieldOpts, givenValue)
      if (valueType === String) {
        // the '\0' character cannot be stored in string fields. If you need to
        // store a string containing this character, then you need to store it
        // inside of an object field, e.g.,
        // doc.someObjField = { myString: '\0' } is okay
        if (givenValue.indexOf('\0') !== -1) {
          throw new InvalidFieldError(
            fieldName, 'cannot put null bytes in strings in compound values')
        }
        pieces.push(givenValue)
      } else {
        pieces.push(stableStringify(givenValue))
      }
    }

    // ` becomes ``  (because we're going to use ` as an escape sequence)
    // / becomes `F  (because / is not allowed in Firestore doc keys)
    const ret = pieces.join('\0')
    const escaped = ret.replace(/`/g, '``').replace(/[/]/g, '`F')
    return escaped
  }

  /**
   * Returns the map which corresponds to the given compound value string
   *
   * This method throws {@link InvalidFieldError} if the decoded string does
   * not match the required schema.
   *
   * @param {Array<String>} keyOrder order of keys in the string representation
   * @param {String} strVal the string representation of a compound value
   * @package
   */
  static __decodeCompoundValue (keyOrder, val) {
    // Assume val is otherwise a string
    const unescaped = val.replace(/`F/g, '/').replace(/``/g, '`')
    const pieces = unescaped.split('\0')
    if (pieces.length !== keyOrder.length) {
      throw new InvalidFieldError(
        'KEY', 'failed to parse key: incorrect number of components')
    }

    const compoundID = {}
    for (let i = 0; i < pieces.length; i++) {
      const piece = pieces[i]
      const fieldName = keyOrder[i]
      const fieldOpts = this._attrs[fieldName]
      const valueType = SCHEMA_TYPE_TO_JS_TYPE_MAP[fieldOpts.schema.type]
      if (valueType === String) {
        compoundID[fieldName] = piece
      } else {
        compoundID[fieldName] = JSON.parse(piece)
      }

      validateValue(fieldName, fieldOpts, compoundID[fieldName])
    }
    return compoundID
  }

  /**
   * Returns a Key identifying a unique document in this model's DB collection.
   * @param {*} vals map of key component names to values; if there is
   *   only one partition key field (whose type is not object), then this MAY
   *   instead be just that field's value.
   * @returns {Key} a Key object.
   */
  static key (vals, ignoreData = false) {
    const processedVals = this.__splitKeysAndDataWithPreprocessing(vals)
    const [encodedKey, keyComponents, data] = processedVals

    // ensure that vals only contained key components (no data components)
    const dataKeys = Object.keys(data)
    if (dataKeys.length && !ignoreData) {
      dataKeys.sort()
      throw new InvalidParameterError('vals',
        `received non-key fields: ${dataKeys.join(', ')}`)
    }
    return new Key(this, encodedKey, keyComponents)
  }

  /**
   * Returns a Data fully describing a unique document in this model's DB collection.
   * @param {*} vals like the argument to key() but also includes non-key data
   * @returns {Data} a Data object for use with tx.create() or
   *   tx.get(..., { createIfMissing: true })
   */
  static data (vals) {
    return new Data(this, ...this.__splitKeysAndDataWithPreprocessing(vals))
  }

  /** @package */
  static __splitKeysAndDataWithPreprocessing (vals) {
    // if we only have one key component, then the `_id` **MAY** just be the
    // value rather than a map of key component names to values
    this.__doOneTimeModelPrep()
    const pKeyOrder = this.__keyOrder
    if (pKeyOrder.length === 1) {
      const pFieldName = pKeyOrder[0]
      if (!(vals instanceof Object) || !vals[pFieldName]) {
        vals = { [pFieldName]: vals }
      }
    }
    if (!(vals instanceof Object)) {
      throw new InvalidParameterError('values',
        'should be an object mapping key component names to values')
    }
    return this.__splitKeysAndData(vals)
  }

  /**
   * Returns the document path to this object.
   */
  toString () {
    return this.__key.docRef.path
  }

  toJSON () {
    return this.getSnapshot()
  }

  /**
   * Return snapshot of the model, all fields included.
   * @param {Object} params
   * @param {Boolean} params.initial Whether to return the initial state
   * @param {Boolean} params.dbKeys Whether to return _id instead of
   *   raw key fields.
   * @param {Boolean} params.omitKey whether to omit the key
   */
  getSnapshot ({ initial = false, dbKeys = false, omitKey = false } = {}) {
    const ret = {}
    if (dbKeys && !omitKey) {
      if (!initial || !this.isNew) {
        assert.ok(typeof this._id === 'string')
        ret._id = this._id
      } else {
        ret._id = undefined
      }
    }
    for (const [name, getter] of Object.entries(this.__attr_getters)) {
      const field = getter()
      if (field.isKey) {
        if (dbKeys || omitKey) {
          continue
        }
      }
      if (initial) {
        ret[name] = field.__initialValue
      } else {
        ret[name] = field.__value
      }
    }
    return ret
  }
}