const assert = require('assert')

let ajv // only defined if needed
const deepcopy = require('rfdc')() // cspell:disable-line

/**
 * Thrown if a compiled schema validator is asked to validate an invalid value.
 */
class ValidationError extends Error {
  /**
   * @param {string} name a user-provided name describing the schema
   * @param {*} badValue the value which did not validate
   * @param {Object} errors how badValue failed to conform to the schema
   * @param {Object} expectedSchema The JSON schema used in schema validation
   */
  constructor (name, badValue, errors, expectedSchema) {
    super(`Validation Error: ${name}`)
    this.badValue = badValue
    this.validationErrors = errors
    this.expectedSchema = expectedSchema
    // istanbul ignore next
    if (['localhost', 'webpack'].includes(process.env.NODE_ENV)) {
      console.error(JSON.stringify(errors, null, 2))
    }
  }
}

const INT32_MAX = Math.pow(2, 31) - 1
const INT32_MIN = -Math.pow(2, 31)
const TIMESTAMP_MIN = '2000-01-01T00:00:00Z'
const TIMESTAMP_MAX = '9999-01-01T00:00:00Z'
const EPOCH_IN_MILLISECONDS_MIN = new Date(TIMESTAMP_MIN).getTime()
const EPOCH_IN_MILLISECONDS_MAX = new Date(TIMESTAMP_MAX).getTime()
const EPOCH_IN_SECONDS_MIN = EPOCH_IN_MILLISECONDS_MIN / 1000
const EPOCH_IN_SECONDS_MAX = EPOCH_IN_MILLISECONDS_MAX / 1000

// javascript is limited in how it can represent >53 bit numbers
// so 2^62 is naturally the best we can do
const INT64_MAX = Math.pow(2, 62)
const INT64_MIN = -Math.pow(2, 62)

/**
 * The base schema object
 */
class BaseSchema {
  /**
   * The json schema type
   */
  static JSON_SCHEMA_TYPE

  /**
   * The max* property name.
   */
  static MAX_PROP_NAME

  /**
   * The min* property name.
   */
  static MIN_PROP_NAME

  /**
   * Constructs a schema object
   */
  constructor () {
    /**
     * Flag to indicate whether an object is a Todea Schema object.
     */
    this.isTodeaSchema = true

    /**
     * For compatibility with fluent-schema. Indicates if an object is a
     * fluent-schema object.
     */
    this.isFluentSchema = true

    /**
     * Stores json schema properties, e.g. type, description, maxLength, etc...
     */
    this.__properties = {}

    /**
     * Indicates whether an object is locked. See {@link lock}.
     */
    this.__isLocked = false
    this.__isOptional = false
    this.__setProp('type', this.constructor.JSON_SCHEMA_TYPE)
  }

  /**
   * Locks a Todea Schema object from modifications.
   */
  lock () {
    this.__isLocked = true
    return this
  }

  /**
   * Sets a value in __properties. Throws if object is locked (unless the
   * property is allowed to be overridden), or property with
   * the same name already exists and override is not allowed.
   * @param {String} name Name of the property
   * @param {*} val The value for the property
   * @param {Object} [options={}]
   * @param {Boolean} [options.allowOverride=false] If true property override
   *   is allowed.
   */
  __setProp (name, val, { allowOverride = false } = {}) {
    assert.ok(!this.__isLocked || allowOverride,
      'Schema is locked. Call copy then further modify the schema')
    assert.ok(allowOverride ||
       !Object.prototype.hasOwnProperty.call(this.__properties, name),
      `Property ${name} is already set.`)
    const shouldCopy = this.__isLocked || (allowOverride &&
      Object.prototype.hasOwnProperty.call(this.__properties, name))
    const ret = shouldCopy ? this.copy() : this
    ret.__properties[name] = val
    if (this.__isLocked) {
      ret.lock()
    }
    return ret
  }

  /**
   * @param {String} name Name of a property
   * @return The value associated with name.
   */
  getProp (name) {
    return this.__properties[name]
  }

  /**
   * If property with name does not exist, the default value is set.
   * @param {String} name
   * @param {*} defaultValue
   * @return The value associated with name.
   */
  __setDefaultProp (name, defaultValue) {
    if (!Object.prototype.hasOwnProperty.call(this.__properties, name)) {
      this.__setProp(name, defaultValue)
    }
    return this.getProp(name)
  }

  /**
   * Sets a title.
   * @param {String} t The title of the schema.
   */
  title (t) {
    assert.ok(typeof t === 'string', 'Title must be a string.')
    return this.__setProp('title', t, { allowOverride: true })
  }

  /**
   * Sets a description.
   * @param {String|Array<String>} t The description of the schema. If an array
   *   of strings are passed in, they will be joined by a space to form the
   *   description.
   */
  desc (d) {
    assert.ok(typeof d === 'string', 'Description must be a string.')
    d = d.trim().replace(/\n/g, ' ')
    return this.__setProp('description', d, { allowOverride: true })
  }

  /**
   * Sets a default value for schema.
   *
   * According to JsonSchema, default value is just metadata and does not
   * serve any validation purpose with in JsonSchema. External tools may
   * choose to use this value to setup defaults, and implementations of
   * JsonSchema validator may choose to validate the type of default values,
   * but it's not required. Since when the default is used to populate the
   * json, there will be something downstream that validates the json and
   * catches issues, we omit schema validation for simplicity.
   *
   * @param {*} d The default value.
   */
  default (d) {
    assert(d !== undefined, 'Default value must be defined')
    Object.freeze(d)
    return this.__setProp('default', d)
  }

  getDefault () {
    return this.properties().default
  }

  hasDefault () {
    return Object.prototype.hasOwnProperty.call(this.properties(), 'default')
  }

  /**
   * Marks a schema as optional. Schemas are required by default.
   */
  optional () {
    assert(!this.__isLocked, 'Schema is locked.')
    this.__isOptional = true
    return this
  }

  /**
   * Convenient getter indicates if the schema is required / not optional.
   * See {@link optional}.
   */
  get required () {
    return !this.__isOptional
  }

  /**
   * Sets schemas readOnly property.
   * @param {Boolean} [r=true] If the schema value should be readOnly.
   */
  readOnly (r = true) {
    return this.__setProp('readOnly', r)
  }

  /**
   * Updates schemas examples.
   * @param {Array<String|Array<String>>} es A list of examples. Each example
   *   may be a string, or a list of strings. In case of a list of strings, the
   *   strings will be joined by a space character and used as one example.
   */
  examples (es) {
    assert.ok(Array.isArray(es), 'Examples must be an array')
    es = es.map(e => {
      return Array.isArray(e) ? e.join(' ') : e
    })
    return this.__setProp('examples', es, { allowOverride: true })
  }

  /**
   * Returns a JSON Schema. It exists for compatibility with fluent-schema.
   */
  valueOf () {
    return this.jsonSchema()
  }

  /**
   * The visitable in a visitor pattern. Used for exporting schema.
   * @param {Exporter} visitor a schema exporter. @see JSONSchemaExporter
   */
  // istanbul ignore next
  export (visitor) {
    throw new Error('Subclass must override')
  }

  properties () {
    return this.__properties
  }

  /**
   * @return JSON Schema with the schema version keyword at the root level.
   */
  jsonSchema () {
    const exporter = new JSONSchemaExporter()
    return exporter.export(this)
  }

  /**
   * Returns a validator function which throws ValidationError if the value it
   * is asked to validate does not match the schema.
   *
   * Locks the current schema.
   *
   * @param {string} name the name of this schema (to distinguish errors)
   * @param {*} [compiler] the ajv or equivalent JSON schema compiler to use
   * @param {returnSchemaToo} [returnSchemaToo] whether to return jsonSchema as
   *   well as the validator
   * @returns {Function} call on a value to validate it; throws on error
   */
  compile (name, compiler, returnSchemaToo) {
    assert.ok(name, 'name is required')
    if (!compiler) {
      if (!ajv) {
        ajv = new (require('ajv'))({
          allErrors: true,
          useDefaults: true,
          strictSchema: false
        })
      }
      compiler = ajv
    }
    this.lock()
    const jsonSchema = this.jsonSchema()
    const validate = compiler.compile(jsonSchema)
    const assertValid = v => {
      if (!validate(v)) {
        throw new ValidationError(name, v, validate.errors, jsonSchema)
      }
    }
    if (returnSchemaToo) {
      return { jsonSchema, assertValid }
    }
    return assertValid
  }

  /**
   * See {@link compile}.
   * @returns {Object} contains jsonSchema and assertValid
   */
  getValidatorAndJSONSchema (name, compiler) {
    return this.compile(name, compiler, true)
  }

  /**
   * @return A copy of the Todea Schema object. Locked objects become unlocked.
   *
   */
  copy () {
    const ret = new this.constructor()
    ret.__properties = deepcopy(this.__properties)
    ret.__isOptional = this.__isOptional
    return ret
  }

  // max / min support
  /**
   * Validate input to min/max.
   * @param {String} name Property name
   * @param {Integer} val A non-negative integer for min/max.
   */
  __validateRangeProperty (name, val) {
    assert.ok(Number.isInteger(val), `${name} must be an integer`)
    assert.ok(val >= 0, `${name} must be a non-negative number`)
  }

  /**
   * Set a min property depending on schema type.
   * @param {Integer} val A non-negative integer for min/max.
   */
  min (val) {
    const name = this.constructor.MIN_PROP_NAME
    this.__validateRangeProperty(name, val)
    const max = this.getProp(this.constructor.MAX_PROP_NAME)
    assert.ok(max === undefined || max >= val, 'min must be less than max')
    return this.__setProp(name, val)
  }

  /**
   * Set a max property depending on schema type.
   * @param {Integer} val A non-negative integer for min/max.
   */
  max (val) {
    const name = this.constructor.MAX_PROP_NAME
    this.__validateRangeProperty(name, val)
    const min = this.getProp(this.constructor.MIN_PROP_NAME)
    assert.ok(min === undefined || min <= val, 'max must be more than min')
    return this.__setProp(name, val)
  }
}

/**
 * The ObjectSchema class.
 */
class ObjectSchema extends BaseSchema {
  static JSON_SCHEMA_TYPE = 'object'
  static MAX_PROP_NAME = 'maxProperties'
  static MIN_PROP_NAME = 'minProperties'

  /**
   * Creates an object schema object.
   * @param {Object} [props={}] Keys must be strings, values must be schema
   *   objects. Passing props is the same as calling S.obj().props(props).
   */
  constructor (props = {}) {
    super()
    this.objectSchemas = {}
    this.patternSchemas = {}
    this.props(props)
  }

  /**
   * Set an object schema's object property.
   * @param {String} name The name of the property.
   * @param {BaseSchema} schema Any subclass of BaseSchema. Schema gets locked.
   */
  prop (name, schema) {
    assert.ok(!this.__isLocked,
      'Schema is locked. Call copy then further modify the schema')
    assert.ok(typeof name === 'string', 'Property name must be strings.')
    const properties = this.__setDefaultProp('properties', {})
    assert.ok(!Object.prototype.hasOwnProperty.call(properties, name),
      `Property with key ${name} already exists`)
    assert.ok(schema !== undefined, `Property ${name} must define a schema`)

    this.objectSchemas[name] = schema.lock()
    properties[name] = schema.properties()
    if (schema.required) {
      this.__setDefaultProp('required', []).push(name)
    }
    return this
  }

  /**
   * A mapping of property names to schemas. Calls this.prop() in a loop.
   * @param {Object} props Keys must be strings, values must be schema
   *   objects.
   */
  props (props) {
    for (const [name, p] of Object.entries(props)) {
      this.prop(name, p)
    }
    return this
  }

  /**
   * A mapping of propertyProperties to schemas.
   * @param {Object} props Keys must be regex, values must be schema
   */
  patternProps (props) {
    for (const [name, schema] of Object.entries(props)) {
      const properties = this.__setDefaultProp('patternProperties', {})
      assert.ok(!Object.prototype.hasOwnProperty.call(properties, name),
        `Pattern ${name} already exists`)
      const anchoredName = getAnchoredPattern(name)
      this.patternSchemas[anchoredName] = schema.lock()
      properties[anchoredName] = schema.properties()
    }
    return this
  }

  copy () {
    const ret = super.copy()
    Object.assign(ret.objectSchemas, this.objectSchemas)
    Object.assign(ret.patternSchemas, this.patternSchemas)
    return ret
  }

  properties () {
    const ret = super.properties()
    // Allow any key if no key is defined.
    const hasProperty = Object.keys(this.objectSchemas).length > 0 ||
      Object.keys(this.patternSchemas).length > 0
    const hasAdditionalProperties = !!this.additionalProperties // make it bool
    ret.additionalProperties = !hasProperty || hasAdditionalProperties
    return ret
  }

  export (visitor) {
    return visitor.exportObject(this)
  }
}

/**
 * The ArraySchema class.
 */
class ArraySchema extends BaseSchema {
  static JSON_SCHEMA_TYPE = 'array'
  static MAX_PROP_NAME = 'maxItems'
  static MIN_PROP_NAME = 'minItems'

  /**
   * Creates an array schema object.
   * @param {BaseSchema} [items] An optional parameter to items(). If provided,
   *   it is the same as calling S.arr().items(items).
   */
  constructor (items) {
    super()
    this.itemsSchema = undefined
    if (items) {
      this.items(items)
    }
  }

  /**
   * Set the schema for items in array
   * @param {BaseSchema} items Any subclass of BaseSchema. Schema gets locked.
   */
  items (items) {
    assert.ok(!this.itemsSchema, 'Items is already set.')
    this.itemsSchema = items.lock()
    this.__setProp('items', items.properties())
    return this
  }

  copy () {
    const ret = super.copy()
    ret.itemsSchema = this.itemsSchema
    return ret
  }

  export (visitor) {
    return visitor.exportArray(this)
  }
}

/**
 * The NumberSchema class.
 */
class NumberSchema extends BaseSchema {
  static JSON_SCHEMA_TYPE = 'number'
  static MAX_PROP_NAME = 'maximum'
  static MIN_PROP_NAME = 'minimum'

  constructor () {
    super()
    this.__isFloat = false
  }

  /**
   * Validate input to min/max.
   * @param {String} name Property name
   * @param {Integer} val A finite number for min/max.
   */
  __validateRangeProperty (name, val) {
    assert.ok(Number.isFinite(val), `${name} must be a number`)
  }

  asFloat () {
    assert(!this.__isLocked, 'Schema is locked')
    this.__isFloat = true
    return this
  }

  get isFloat () {
    return this.__isFloat
  }

  export (visitor) {
    return visitor.exportNumber(this)
  }

  copy () {
    const ret = super.copy()
    ret.__isFloat = this.__isFloat
    return ret
  }
}

/**
 * The IntegerSchema class.
 */
class IntegerSchema extends NumberSchema {
  static JSON_SCHEMA_TYPE = 'integer'

  /**
   * Validate input to min/max.
   * @param {String} name Property name
   * @param {Integer} val An integer for min/max.
   */
  __validateRangeProperty (name, val) {
    assert.ok(Number.isInteger(val), `${name} must be an integer`)
  }

  /**
   * sets limit on how large max or min can be.
   * Validates current min/max to ensure they work correctly
   */
  __setSafeRangeLimit (val) {
    const max = this.getProp(this.constructor.MAX_PROP_NAME)
    if (max === undefined) {
      this.max(val)
    } else {
      assert.ok(max <= val, `max cannot exceed ${val}`)
    }
    const min = this.getProp(this.constructor.MIN_PROP_NAME)
    if (min === undefined) {
      this.min(-val)
    } else {
      assert.ok(min >= -val, `min must be larger than ${-val}`)
    }
    return this
  }

  /**
   * applies range for Int32 values
   */
  asInt32 () {
    return this.__setSafeRangeLimit(INT32_MAX)
  }

  /**
   * applies range for int64 values
   */
  asInt64 () {
    return this.__setSafeRangeLimit(INT64_MAX)
  }

  asFloat = undefined

  export (visitor) {
    return visitor.exportInteger(this)
  }
}

/**
 * The StringSchema class.
 */
class StringSchema extends BaseSchema {
  static JSON_SCHEMA_TYPE = 'string'
  static MAX_PROP_NAME = 'maxLength'
  static MIN_PROP_NAME = 'minLength'

  /**
   * Set valid values for the string schema.
   * @param {Array<String>} validValues Valid values for the string. There must
   *   be at least 2 valid values.
   */
  enum (validValues) {
    const values = Array.isArray(validValues) ? validValues : [...arguments]
    assert(values.length >= 1, 'Enum must contain at least 1 value.')
    return this.__setProp('enum', values)
  }

  /**
   * A pattern for the string.
   * @param {String|RegExp} pattern The pattern for the string. Can be a string
   *   with regex syntax, or a RegExp object.
   */
  pattern (pattern) {
    if (pattern instanceof RegExp) {
      pattern = pattern.source
    }
    assert(typeof pattern === 'string', 'Pattern must be a string')
    const anchoredPattern = getAnchoredPattern(pattern)
    return this.__setProp('pattern', anchoredPattern)
  }

  export (visitor) {
    return visitor.exportString(this)
  }
}

/**
 * The BooleanSchema class.
 */
class BooleanSchema extends BaseSchema {
  static JSON_SCHEMA_TYPE = 'boolean'

  export (visitor) {
    return visitor.exportBoolean(this)
  }
}

/**
 * The MapSchema class.
 */
class MapSchema extends ObjectSchema {
  constructor () {
    super()
    // deprecate obj methods
    this.prop = undefined
    this.props = undefined
    this.patternProps = undefined

    this.finalized = false
    this.keySchema = undefined
    this.valueSchema = undefined
  }

  /**
   * Set a key pattern for the map.
   * @param {String} keyPattern A pattern for keys
   */
  keyPattern (pattern) {
    assert(!this.keySchema, 'key pattern already set')
    this.keySchema = S.str.pattern(pattern).lock()
    this.__tryFinalizeSchema()
    return this
  }

  /**
   * Set a value schema for the map.
   * @param {BaseSchema} value Any subclass of BaseSchema for the values of map
   */
  value (value) {
    assert(!this.valueSchema, 'value schema already set')
    assert(value.required, 'value must be required')
    this.valueSchema = value.lock()
    this.__tryFinalizeSchema()
    return this
  }

  lock () {
    this.__finalizeSchema()
    return super.lock()
  }

  __finalizeSchema () {
    assert(this.valueSchema, 'Must have a value schema')
    if (!this.keySchema) {
      this.keySchema = S.str
    }
    this.__tryFinalizeSchema()
  }

  __tryFinalizeSchema () {
    if (this.keySchema && this.valueSchema && !this.finalized) {
      this.finalized = true
      super.patternProps({
        [this.keySchema?.getProp('pattern') ?? '.*']: this.valueSchema
      })
    }
  }

  export (visitor) {
    this.__finalizeSchema()
    return visitor.exportMap(this)
  }

  copy () {
    const ret = super.copy()
    ret.finalized = this.finalized
    ret.keySchema = this.keySchema.copy()
    ret.valueSchema = this.valueSchema.copy()
    return ret
  }
}

class MediaSchema extends StringSchema {
  type (t) {
    this.__setProp('contentMediaType', t)
    return this
  }

  encoding (e) {
    assert(['binary', 'base64', 'utf-8'].includes(e),
      'Encoding must be binary, base64 or utf-8')
    this.__setProp('contentEncoding', e)
    return this
  }

  export (visitor) {
    return visitor.exportMedia(this)
  }
}

class JSONSchemaExporter {
  constructor () {
    const methods = [
      'exportString',
      'exportInteger',
      'exportNumber',
      'exportObject',
      'exportArray',
      'exportBoolean',
      'exportMap',
      'exportMedia'
    ]

    for (const method of methods) {
      Object.defineProperty(this, method, {
        get: () => {
          return (schema) => {
            return schema.properties()
          }
        }
      })
    }
  }

  export (schema) {
    const ret = deepcopy(schema.export(this))
    ret.$schema = 'http://json-schema.org/draft-07/schema#'
    return ret
  }
}

/**
 * The S object to be exported.
 * Noteworthily, it is safe to deprecate certain schema types simply by
 * deleting the corresponding accessor.
 */
class S {
  /**
   * @param {Object} object See {@link ObjectSchema#constructor}
   * @return A new ObjectSchema object.
   */
  static obj (object) { return new ObjectSchema(object) }

  /**
   * @param {BaseSchema} schema See {@link ArraySchema#constructor}
   * @return A new ArraySchema object.
   */
  static arr (schema) { return new ArraySchema(schema) }

  /**
   * Get a new NumberSchema object.
   */
  static get double () { return new NumberSchema() }

  /**
   * Get a new IntegerSchema object.
   */
  static get int () { return new IntegerSchema() }

  /**
   * Get a new StringSchema object.
   */
  static get str () { return new StringSchema() }

  /**
   * Get a new BooleanSchema object.
   */
  static get bool () { return new BooleanSchema() }

  /**
   * Get a new MapSchema object.
   */
  static get map () { return new MapSchema() }

  /**
   * Get a new MediaSchema object.
   */
  static get media () { return new MediaSchema() }
  /**
   * Lock all schemas in a dictionary (in-place).
   * @param {Object<Schema>} schemas a map of schema values
   * @returns the input map of schema values
   */
  static lock (schemas) {
    Object.values(schemas).forEach(x => x.lock())
    return schemas
  }

  /**
   * Sets all schemas as optional (in-place).
   * @param {Object<Schema>} schemas a map of schema values
   * @returns the input map of schema values
   */
  static optional (schemas) {
    Object.values(schemas).forEach(x => x.optional())
    return schemas
  }

  static PATTERN = {
    UUID_PATTERN: /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/
  }

  /**
   * Common schemas.
   */
  static SCHEMAS = S.lock({
    UUID: S.str.desc('An UUID. It is normally generated by calling uuidv4().')
      .pattern(S.PATTERN.UUID_PATTERN),
    STR_ANDU: S.str.desc('Only hyphens, underscores, letters and numbers are permitted.')
      .pattern(/^[-_a-zA-Z0-9]+$/),
    // oversimplified, quick regex to check that a string looks like an email
    STR_EMAIL: S.str.pattern(/^[^A-Z ]+@.+$/)
      .desc('an e-mail address (lowercase only)').lock(),
    TIMESTAMP: S.str
      .pattern(/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d{3}Z/)
      .desc(`An UTC timestamp with millisecond precision, for example,
        2021-02-15T20:15:59.321Z`),
    TIMESTAMP_WITH_TZ: S.str
      .pattern(/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d(\.\d*)?([+-][0-2]\d:[0-5]\d|Z)/)
      .desc(`Timestamp with time zone and optional millisecond precision,
        for example,
        2021-02-15T20:15:59Z,
        2022-09-15T16:48:28.9097226Z,
        2021-02-15T11:55:20-05:00,
        2021-02-15T11:55:20.9097226+08:00`),
    EPOCH_IN_SECONDS: S.int.min(EPOCH_IN_SECONDS_MIN)
      .max(EPOCH_IN_SECONDS_MAX).desc(`Unix epoch time format in seconds from
        ${TIMESTAMP_MIN} to ${TIMESTAMP_MAX}.`),
    EPOCH_IN_MILLISECONDS: S.int.min(EPOCH_IN_MILLISECONDS_MIN)
      .max(EPOCH_IN_MILLISECONDS_MAX).desc(`Unix epoch time format in
        milliseconds from ${TIMESTAMP_MIN} to ${TIMESTAMP_MAX}.`).asInt64()
  })

  /** Thrown if validation fails. */
  static ValidationError = ValidationError

  static INT32_MAX = INT32_MAX

  static INT32_MIN = INT32_MIN

  static INT64_MAX = INT64_MAX

  static INT64_MIN = INT64_MIN
}

function getAnchoredPattern (pattern) {
  let anchoredName = pattern
  if (pattern[0] !== '^') {
    anchoredName = '^' + pattern
  }
  if (pattern[pattern.length - 1] !== '$') {
    anchoredName += '$'
  }
  return anchoredName
}

module.exports = S