//
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import core, {
  AttachedDoc,
  Class,
  Client,
  Collection,
  Doc,
  DocumentUpdate,
  Hierarchy,
  Lookup,
  Obj,
  Ref,
  RefTo,
  ReverseLookup,
  ReverseLookups,
  Space,
  TxOperations
} from '@hcengineering/core'
import type { IntlString } from '@hcengineering/platform'
import { getResource } from '@hcengineering/platform'
import { AttributeCategory, getAttributePresenterClass, KeyedAttribute } from '@hcengineering/presentation'
import {
  AnyComponent,
  ErrorPresenter,
  getCurrentLocation,
  getPlatformColorForText,
  Location,
  locationToUrl
} from '@hcengineering/ui'
import type { BuildModelOptions, Viewlet } from '@hcengineering/view'
import view, { AttributeModel, BuildModelKey } from '@hcengineering/view'
import { writable } from 'svelte/store'
import plugin from './plugin'
import { noCategory } from './viewOptions'

/**
 * Define some properties to be used to show component until data is properly loaded.
 */
export interface LoadingProps {
  length: number
}

/**
 * @public
 */
export async function getObjectPresenter (
  client: Client,
  _class: Ref<Class<Obj>>,
  preserveKey: BuildModelKey,
  isCollectionAttr: boolean = false
): Promise<AttributeModel> {
  const hierarchy = client.getHierarchy()
  const mixin = isCollectionAttr ? view.mixin.CollectionPresenter : view.mixin.ObjectPresenter
  const clazz = hierarchy.getClass(_class)
  let mixinClazz = hierarchy.getClass(_class)
  let presenterMixin = hierarchy.as(clazz, mixin)
  while (presenterMixin.presenter === undefined && mixinClazz.extends !== undefined) {
    presenterMixin = hierarchy.as(mixinClazz, mixin)
    mixinClazz = hierarchy.getClass(mixinClazz.extends)
  }
  if (presenterMixin.presenter === undefined) {
    throw new Error(
      `object presenter not found for class=${_class}, mixin=${mixin}, preserve key ${JSON.stringify(preserveKey)}`
    )
  }
  const presenter = await getResource(presenterMixin.presenter)
  const key = preserveKey.sortingKey ?? preserveKey.key
  const sortingKey = Array.isArray(key)
    ? key
    : clazz.sortingKey !== undefined
      ? key.length > 0
        ? key + '.' + clazz.sortingKey
        : clazz.sortingKey
      : key
  return {
    key: preserveKey.key,
    _class,
    label: preserveKey.label ?? clazz.label,
    presenter,
    props: preserveKey.props,
    sortingKey,
    collectionAttr: isCollectionAttr,
    isLookup: false
  }
}

/**
 * @public
 */
export async function getListItemPresenter (client: Client, _class: Ref<Class<Obj>>): Promise<AnyComponent | undefined> {
  const clazz = client.getHierarchy().getClass(_class)
  const presenterMixin = client.getHierarchy().as(clazz, view.mixin.ListItemPresenter)
  if (presenterMixin.presenter === undefined) {
    if (clazz.extends !== undefined) {
      return await getListItemPresenter(client, clazz.extends)
    }
  }
  return presenterMixin?.presenter
}

/**
 * @public
 */
export async function getObjectPreview (client: Client, _class: Ref<Class<Obj>>): Promise<AnyComponent | undefined> {
  const clazz = client.getHierarchy().getClass(_class)
  const presenterMixin = client.getHierarchy().as(clazz, view.mixin.PreviewPresenter)
  if (presenterMixin.presenter === undefined) {
    if (clazz.extends !== undefined) {
      return await getObjectPreview(client, clazz.extends)
    }
  }
  return presenterMixin?.presenter
}

async function getAttributePresenter (
  client: Client,
  _class: Ref<Class<Obj>>,
  key: string,
  preserveKey: BuildModelKey
): Promise<AttributeModel> {
  const hierarchy = client.getHierarchy()
  const attribute = hierarchy.getAttribute(_class, key)
  const presenterClass = getAttributePresenterClass(hierarchy, attribute)
  const isCollectionAttr = presenterClass.category === 'collection'
  const mixin = isCollectionAttr ? view.mixin.CollectionPresenter : view.mixin.AttributePresenter
  const clazz = hierarchy.getClass(presenterClass.attrClass)
  let presenterMixin = hierarchy.as(clazz, mixin)
  let parent = clazz.extends
  while (presenterMixin.presenter === undefined && parent !== undefined) {
    const pclazz = hierarchy.getClass(parent)
    presenterClass.attrClass = parent
    presenterMixin = hierarchy.as(pclazz, mixin)
    parent = pclazz.extends
  }
  if (presenterMixin.presenter === undefined) {
    throw new Error('attribute presenter not found for ' + JSON.stringify(preserveKey))
  }
  const resultKey = preserveKey.sortingKey ?? preserveKey.key
  const sortingKey = Array.isArray(resultKey)
    ? resultKey
    : attribute.type._class === core.class.ArrOf
      ? resultKey + '.length'
      : resultKey
  const presenter = await getResource(presenterMixin.presenter)

  return {
    key: preserveKey.key,
    sortingKey,
    _class: presenterClass.attrClass,
    label: preserveKey.label ?? attribute.shortLabel ?? attribute.label,
    presenter,
    props: preserveKey.props,
    icon: presenterMixin.icon,
    attribute,
    collectionAttr: isCollectionAttr,
    isLookup: false
  }
}

export async function getPresenter<T extends Doc> (
  client: Client,
  _class: Ref<Class<T>>,
  key: BuildModelKey,
  preserveKey: BuildModelKey,
  lookup?: Lookup<T>,
  isCollectionAttr: boolean = false
): Promise<AttributeModel> {
  if (key.presenter !== undefined) {
    const { presenter, label, sortingKey } = key
    return {
      key: key.key ?? '',
      sortingKey: sortingKey ?? '',
      _class,
      label: label as IntlString,
      presenter: typeof presenter === 'string' ? await getResource(presenter) : presenter,
      props: preserveKey.props,
      collectionAttr: isCollectionAttr,
      isLookup: false
    }
  }
  if (key.key.length === 0) {
    return await getObjectPresenter(client, _class, preserveKey, isCollectionAttr)
  } else {
    if (key.key.startsWith('$lookup')) {
      if (lookup === undefined) {
        throw new Error(`lookup class does not provided for ${key.key}`)
      }
      return await getLookupPresenter(client, _class, key, preserveKey, lookup)
    }
    return await getAttributePresenter(client, _class, key.key, preserveKey)
  }
}

function getKeyLookup<T extends Doc> (
  hierarchy: Hierarchy,
  _class: Ref<Class<T>>,
  key: string,
  lookup: Lookup<T>,
  lastIndex: number = 1
): Lookup<T> {
  if (!key.startsWith('$lookup')) return lookup
  const parts = key.split('.')
  const attrib = parts[1]
  const attribute = hierarchy.getAttribute(_class, attrib)
  if (hierarchy.isDerived(attribute.type._class, core.class.RefTo)) {
    const lookupClass = (attribute.type as RefTo<Doc>).to
    const index = key.indexOf('$lookup', lastIndex)
    if (index === -1) {
      if ((lookup as any)[attrib] === undefined) {
        ;(lookup as any)[attrib] = lookupClass
      }
    } else {
      let nested = Array.isArray((lookup as any)[attrib]) ? (lookup as any)[attrib][1] : {}
      nested = getKeyLookup(hierarchy, lookupClass, key.slice(index), nested)
      ;(lookup as any)[attrib] = [lookupClass, nested]
    }
  } else if (hierarchy.isDerived(attribute.type._class, core.class.Collection)) {
    if ((lookup as any)._id === undefined) {
      ;(lookup as any)._id = {}
    }
    ;(lookup as any)._id[attrib] = (attribute.type as Collection<AttachedDoc>).of
  }
  return lookup
}

export function buildConfigLookup<T extends Doc> (
  hierarchy: Hierarchy,
  _class: Ref<Class<T>>,
  config: Array<BuildModelKey | string>,
  existingLookup?: Lookup<T>
): Lookup<T> {
  let res: Lookup<T> = {}
  for (const key of config) {
    if (typeof key === 'string') {
      res = getKeyLookup(hierarchy, _class, key, res)
    } else {
      res = getKeyLookup(hierarchy, _class, key.key, res)
    }
  }
  if (existingLookup !== undefined) {
    // Let's merg
    const _id: ReverseLookup = {
      ...((existingLookup as ReverseLookups)._id ?? {}),
      ...((res as ReverseLookups)._id ?? {})
    }
    res = { ...existingLookup, ...res, _id }
  }
  return res
}

export async function buildModel (options: BuildModelOptions): Promise<AttributeModel[]> {
  // eslint-disable-next-line array-callback-return
  const model = options.keys
    .map((key) => (typeof key === 'string' ? { key } : key))
    .map(async (key) => {
      try {
        // Check if it is a mixin attribute configuration
        const pos = key.key.lastIndexOf('.')
        if (pos !== -1) {
          const mixinName = key.key.substring(0, pos) as Ref<Class<Doc>>
          if (!mixinName.includes('$lookup')) {
            const realKey = key.key.substring(pos + 1)
            const rkey = { ...key, key: realKey }
            return {
              ...(await getPresenter(options.client, mixinName, rkey, rkey, options.lookup)),
              castRequest: mixinName,
              key: key.key,
              sortingKey: key.key
            }
          }
        }
        return await getPresenter(options.client, options._class, key, key, options.lookup)
      } catch (err: any) {
        if (options.ignoreMissing ?? false) {
          return undefined
        }
        const stringKey = key.label ?? key.key
        console.error('Failed to find presenter for', key, err)
        const errorPresenter: AttributeModel = {
          key: '',
          sortingKey: '',
          presenter: ErrorPresenter,
          label: stringKey as IntlString,
          _class: core.class.TypeString,
          props: { error: err },
          collectionAttr: false,
          isLookup: false
        }
        return errorPresenter
      }
    })
  return (await Promise.all(model)).filter((a) => a !== undefined) as AttributeModel[]
}

export async function deleteObject (client: TxOperations, object: Doc): Promise<void> {
  if (client.getHierarchy().isDerived(object._class, core.class.AttachedDoc)) {
    const adoc = object as AttachedDoc
    await client
      .removeCollection(object._class, object.space, adoc._id, adoc.attachedTo, adoc.attachedToClass, adoc.collection)
      .catch((err) => console.error(err))
  } else {
    await client.removeDoc(object._class, object.space, object._id).catch((err) => console.error(err))
  }
}

export async function deleteObjects (client: TxOperations, objects: Doc[]): Promise<void> {
  const ops = client.apply('delete')
  for (const object of objects) {
    if (client.getHierarchy().isDerived(object._class, core.class.AttachedDoc)) {
      const adoc = object as AttachedDoc
      await ops
        .removeCollection(object._class, object.space, adoc._id, adoc.attachedTo, adoc.attachedToClass, adoc.collection)
        .catch((err) => console.error(err))
    } else {
      await ops.removeDoc(object._class, object.space, object._id).catch((err) => console.error(err))
    }
  }
  await ops.commit()
}

export function getMixinStyle (id: Ref<Class<Doc>>, selected: boolean): string {
  const color = getPlatformColorForText(id as string)
  return `
    color: ${selected ? '#fff' : 'var(--theme-caption-color)'};
    background: ${color + (selected ? 'ff' : '33')};
    border: 1px solid ${color + (selected ? '0f' : '66')};
  `
}

async function getLookupPresenter<T extends Doc> (
  client: Client,
  _class: Ref<Class<T>>,
  key: BuildModelKey,
  preserveKey: BuildModelKey,
  lookup: Lookup<T>
): Promise<AttributeModel> {
  const lookupClass = getLookupClass(key.key, lookup, _class)
  const lookupProperty = getLookupProperty(key.key)
  const lookupKey = { ...key, key: lookupProperty[0] }
  const model = await getPresenter(client, lookupClass[0], lookupKey, preserveKey, undefined, lookupClass[2])
  model.label = getLookupLabel(client, lookupClass[1], lookupClass[0], lookupKey, lookupProperty[1])
  model.isLookup = true
  return model
}

export function getLookupLabel<T extends Doc> (
  client: Client,
  _class: Ref<Class<T>>,
  lookupClass: Ref<Class<Doc>>,
  key: BuildModelKey,
  attrib: string
): IntlString {
  if (key.label !== undefined) return key.label
  if (key.key === '') {
    try {
      const attribute = client.getHierarchy().getAttribute(_class, attrib)
      return attribute.label
    } catch {}
    const clazz = client.getHierarchy().getClass(lookupClass)
    return clazz.label
  } else {
    const attribute = client.getHierarchy().getAttribute(lookupClass, key.key)
    return attribute.label
  }
}

export function getLookupClass<T extends Doc> (
  key: string,
  lookup: Lookup<T>,
  parent: Ref<Class<T>>
): [Ref<Class<Doc>>, Ref<Class<Doc>>, boolean] {
  const _class = getLookup(key, lookup, parent)
  if (_class === undefined) {
    throw new Error('lookup class does not provided for ' + key)
  }
  return _class
}

export function getLookupProperty (key: string): [string, string] {
  const parts = key.split('$lookup')
  const lastPart = parts[parts.length - 1]
  const split = lastPart.split('.').filter((p) => p.length > 0)
  const prev = split.shift() ?? ''
  const result = split.join('.')
  return [result, prev]
}

function getLookup (
  key: string,
  lookup: Lookup<any>,
  parent: Ref<Class<Doc>>
): [Ref<Class<Doc>>, Ref<Class<Doc>>, boolean] | undefined {
  const parts = key.split('$lookup.').filter((p) => p.length > 0)
  const currentKey = parts[0].split('.').filter((p) => p.length > 0)[0]
  const current = (lookup as any)[currentKey]
  const nestedKey = parts.slice(1).join('$lookup.')
  if (nestedKey.length > 0) {
    if (!Array.isArray(current)) {
      return
    }
    return getLookup(nestedKey, current[1], current[0])
  }
  if (Array.isArray(current)) {
    return [current[0], parent, false]
  }
  if (current === undefined && lookup._id !== undefined) {
    const reverse = (lookup._id as any)[currentKey]
    return reverse !== undefined
      ? Array.isArray(reverse)
        ? [reverse[0], parent, true]
        : [reverse, parent, true]
      : undefined
  }
  return current !== undefined ? [current, parent, false] : undefined
}

export function getBooleanLabel (value: boolean | undefined): IntlString {
  if (value === true) return plugin.string.LabelYes
  if (value === false) return plugin.string.LabelNo
  return plugin.string.LabelNA
}
export function getCollectionCounter (hierarchy: Hierarchy, object: Doc, key: KeyedAttribute): number {
  if (hierarchy.isMixin(key.attr.attributeOf)) {
    return (hierarchy.as(object, key.attr.attributeOf) as any)[key.key]
  }
  return (object as any)[key.key] ?? 0
}

function filterKeys (hierarchy: Hierarchy, keys: KeyedAttribute[], ignoreKeys: string[]): KeyedAttribute[] {
  const docKeys: Set<string> = new Set<string>(hierarchy.getAllAttributes(core.class.AttachedDoc).keys())
  keys = keys.filter((k) => !docKeys.has(k.key))
  keys = keys.filter((k) => !ignoreKeys.includes(k.key))
  return keys
}

export function getFiltredKeys (
  hierarchy: Hierarchy,
  objectClass: Ref<Class<Doc>>,
  ignoreKeys: string[],
  to?: Ref<Class<Doc>>
): KeyedAttribute[] {
  const keys = [...hierarchy.getAllAttributes(objectClass, to).entries()]
    .filter(([, value]) => value.hidden !== true)
    .map(([key, attr]) => ({ key, attr }))

  return filterKeys(hierarchy, keys, ignoreKeys)
}

export interface CategoryKey {
  key: KeyedAttribute
  category: AttributeCategory
}

export function categorizeFields (
  hierarchy: Hierarchy,
  keys: KeyedAttribute[],
  useAsCollection: string[],
  useAsAttribute: string[]
): {
    attributes: CategoryKey[]
    collections: CategoryKey[]
  } {
  const result = {
    attributes: [] as CategoryKey[],
    collections: [] as CategoryKey[]
  }

  for (const key of keys) {
    const cl = getAttributePresenterClass(hierarchy, key.attr)
    if (useAsCollection.includes(key.key)) {
      result.collections.push({ key, category: cl.category })
    } else if (useAsAttribute.includes(key.key)) {
      result.attributes.push({ key, category: cl.category })
    } else if (cl.category === 'collection' || cl.category === 'inplace') {
      result.collections.push({ key, category: cl.category })
    } else if (cl.category === 'array') {
      const attrClass = getAttributePresenterClass(hierarchy, key.attr)
      const clazz = hierarchy.getClass(attrClass.attrClass)
      const mix = hierarchy.as(clazz, view.mixin.ArrayEditor)
      if (mix.editor !== undefined && mix.inlineEditor === undefined) {
        result.collections.push({ key, category: cl.category })
      } else {
        result.attributes.push({ key, category: cl.category })
      }
    } else {
      result.attributes.push({ key, category: cl.category })
    }
  }
  return result
}

export function isCollectionAttr (hierarchy: Hierarchy, key: KeyedAttribute): boolean {
  return hierarchy.isDerived(key.attr.type._class, core.class.Collection)
}

function makeViewletKey (loc?: Location): string {
  loc = loc ?? getCurrentLocation()
  loc.fragment = undefined
  loc.query = undefined
  return 'viewlet' + locationToUrl(loc)
}

export function setActiveViewletId (viewletId: Ref<Viewlet> | null, loc?: Location): void {
  const key = makeViewletKey(loc)
  if (viewletId !== null && viewletId !== undefined) {
    localStorage.setItem(key, viewletId)
  } else {
    localStorage.removeItem(key)
  }
}

export function getActiveViewletId (): Ref<Viewlet> | null {
  const key = makeViewletKey()
  return localStorage.getItem(key) as Ref<Viewlet> | null
}

export type FixedWidthStore = Record<string, number>

export const fixedWidthStore = writable<FixedWidthStore>({})

export const groupBy = (docs: Doc[], key: string): { [key: string]: Doc[] } => {
  return docs.reduce((storage: { [key: string]: Doc[] }, item: Doc) => {
    const group = (item as any)[key] ?? undefined

    storage[group] = storage[group] ?? []

    storage[group].push(item)

    return storage
  }, {})
}

export async function getCategories (
  client: TxOperations,
  _class: Ref<Class<Doc>>,
  docs: Doc[],
  key: string
): Promise<any[]> {
  if (key === noCategory) return [undefined]
  const hierarchy = client.getHierarchy()
  const existingCategories = Array.from(new Set(docs.map((x: any) => x[key] ?? undefined)))
  const attr = hierarchy.getAttribute(_class, key)
  if (attr === undefined) return existingCategories
  const attrClass = getAttributePresenterClass(hierarchy, attr).attrClass
  const clazz = hierarchy.getClass(attrClass)
  const sortFunc = hierarchy.as(clazz, view.mixin.SortFuncs)
  if (sortFunc?.func === undefined) return existingCategories
  const f = await getResource(sortFunc.func)

  return await f(existingCategories)
}

export function getKeyLabel<T extends Doc> (
  client: TxOperations,
  _class: Ref<Class<T>>,
  key: string,
  lookup: Lookup<T> | undefined
): IntlString {
  if (key.startsWith('$lookup') && lookup !== undefined) {
    const lookupClass = getLookupClass(key, lookup, _class)
    const lookupProperty = getLookupProperty(key)
    const lookupKey = { key: lookupProperty[0] }
    return getLookupLabel(client, lookupClass[1], lookupClass[0], lookupKey, lookupProperty[1])
  } else {
    const attribute = client.getHierarchy().getAttribute(_class, key)
    return attribute.label
  }
}

/**
 * @public
 * Implemenation of cosice similarity
 */
export function cosinesim (A: number[], B: number[]): number {
  let dotproduct = 0
  let mA = 0
  let mB = 0
  for (let i = 0; i < A.length; i++) {
    dotproduct += A[i] * B[i]
    mA += A[i] * A[i]
    mB += B[i] * B[i]
  }
  mA = Math.sqrt(mA)
  mB = Math.sqrt(mB)
  const similarity = dotproduct / (mA * mB) // here you needed extra brackets
  return similarity
}

/**
 * Calculate Sørensen–Dice coefficient
 */
export function calcSørensenDiceCoefficient (a: string, b: string): number {
  const first = a.replace(/\s+/g, '')
  const second = b.replace(/\s+/g, '')

  if (first === second) return 1 // identical or empty
  if (first.length < 2 || second.length < 2) return 0 // if either is a 0-letter or 1-letter string

  const firstBigrams = new Map<string, number>()
  for (let i = 0; i < first.length - 1; i++) {
    const bigram = first.substring(i, i + 2)
    const count = (firstBigrams.get(bigram) ?? 0) + 1

    firstBigrams.set(bigram, count)
  }

  let intersectionSize = 0
  for (let i = 0; i < second.length - 1; i++) {
    const bigram = second.substring(i, i + 2)
    const count = firstBigrams.get(bigram) ?? 0

    if (count > 0) {
      firstBigrams.set(bigram, count - 1)
      intersectionSize++
    }
  }

  return (2.0 * intersectionSize) / (first.length + second.length - 2)
}

/**
 * @public
 */
export async function moveToSpace (
  client: TxOperations,
  doc: Doc,
  space: Ref<Space>,
  extra?: DocumentUpdate<any>
): Promise<void> {
  const hierarchy = client.getHierarchy()
  const attributes = hierarchy.getAllAttributes(doc._class)
  for (const [name, attribute] of attributes) {
    if (hierarchy.isDerived(attribute.type._class, core.class.Collection)) {
      const collection = attribute.type as Collection<AttachedDoc>
      const allAttached = await client.findAll(collection.of, { attachedTo: doc._id })
      for (const attached of allAttached) {
        // Do not use extra for childs.
        await moveToSpace(client, attached, space).catch((err) => console.log('failed to move', name, err))
      }
    }
  }
  await client.update(doc, {
    space,
    ...extra
  })
}

/**
 * @public
 */
export function getAdditionalHeader (client: TxOperations, _class: Ref<Class<Doc>>): AnyComponent[] | undefined {
  const hierarchy = client.getHierarchy()
  const clazz = hierarchy.getClass(_class)
  let mixinClazz = hierarchy.getClass(_class)
  let presenterMixin = hierarchy.as(clazz, view.mixin.ListHeaderExtra)
  while (presenterMixin.presenters === undefined && mixinClazz.extends !== undefined) {
    presenterMixin = hierarchy.as(mixinClazz, view.mixin.ListHeaderExtra)
    mixinClazz = hierarchy.getClass(mixinClazz.extends)
  }
  return presenterMixin.presenters
}
