/*
 * Silex website builder, free/libre no-code tool for makers.
 * Copyright (c) 2023 lexoyo and Silex Labs foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

import Backbone from 'backbone'
import { COMPONENT_STATE_CHANGED, DATA_SOURCE_CHANGED, DATA_SOURCE_ERROR, DATA_SOURCE_READY, DataSourceId, Filter, IDataSource, IDataSourceModel, Property, StoredToken } from '../types'
import { DataSourceEditor, DataSourceEditorOptions } from '../types'
import { DataTree } from './DataTree'
import { Component, Page } from 'grapesjs'
import { StoredState, onStateChange } from './state'
import getLiquidFilters from '../filters/liquid'
import getGenericFilters from '../filters/generic'
import { getComponentDebug, NOTIFICATION_GROUP } from '../utils'

/**
 * FIXME: Why sometimes the methods of the data source are in the attributes?
 * @return ds if it has the getTypes method or ds.attributes if it has the getTypes method
 */
export function getDataSourceClass(ds: IDataSource | { attributes: IDataSource }): IDataSource {
  const unknownTyped = ds as Record<string, unknown>
  if(typeof unknownTyped.getTypes === 'function') return ds as IDataSource
  const unknownAttributes = unknownTyped.attributes as Record<string, unknown>
  if(typeof unknownAttributes.getTypes === 'function') return unknownTyped.attributes as IDataSource
  console.error('Data source has no getTypes method', ds)
  throw new Error('Data source has no getTypes method')
}

/**
 * GrapesJs plugin to manage data sources
 */
export class DataSourceManager extends Backbone.Collection<IDataSourceModel> {
  protected dataTree: DataTree
  get filters() {
    return this.dataTree.filters
  }
  set filters(filters: Filter[]) {
    this.dataTree.filters = filters
  }

  constructor(models: IDataSource[], protected editor: DataSourceEditor,  protected options: DataSourceEditorOptions) {
    super(models, options)

    // Make sure the operations are undoable
    this.editor.UndoManager.add(this)

    // Add filters from config
    const filters = (() => {
      // Include preset from filters/
      if (typeof options.filters === 'string') {
        return [
          ...getGenericFilters(editor),
          ...getLiquidFilters(editor),
        ]
      } else {
        // Define filters in the options
        return (options.filters as Filter[])
          .flatMap((filter: Partial<Filter> | string): Filter[] => {
            if (typeof filter === 'string') {
              switch (filter) {
              case 'liquid': return getLiquidFilters(editor)
              case 'generic': return getGenericFilters(editor)
              default: throw new Error(`Unknown filters ${filter}`)
              }
            } else {
              return [{
                ...filter as Partial<Filter>,
                type: 'filter',
              } as Filter]
            }
          })
          .map((filter: Filter)=> ({ ...filter, type: 'filter' })) as Filter[]
      }
    })()

    // Init the data tree
    this.dataTree = new DataTree(editor, {
      dataSources: this.models,
      filters,
    })

    // Update the data tree when the data sources change
    this.on('add update remove change', () => this.modelChanged())
    this.on(DATA_SOURCE_READY, (ds: IDataSource) => this.modelReady(ds))
    this.on(DATA_SOURCE_CHANGED, (e: CustomEvent) => this.modelChanged(e))

    // Start listening to data sources
    this.modelChanged()

    // Relay state changes to the editor
    onStateChange((state: StoredState | null, component: Component) => this.editor.trigger(COMPONENT_STATE_CHANGED, { state, component }))
  }

  /**
   * Get all data sources
   */
  getAll(): IDataSourceModel[] {
    return this.models
  }

  /**
   * Forward events from data sources to the editor
   */
  protected dataChangedBinded = this.dataChanged.bind(this)
  dataChanged(e?: CustomEvent) {
    this.editor.trigger(DATA_SOURCE_CHANGED, e?.detail)
  }

  /**
   * Forward events from data sources to the editor
   */
  protected dataSourceReadyBinded = this.dataSourceReady.bind(this)
  dataSourceReady(ds: IDataSource) {
    this.editor.trigger(DATA_SOURCE_READY, ds)
  }

  /**
   * Forward events from data sources to the editor
   */
  protected dataSourceErrorBinded = this.dataSourceError.bind(this)
  dataSourceError(message: string, ds: IDataSource) {
    this.editor.trigger(DATA_SOURCE_ERROR, message, ds)
  }

  /**
   * Listen to data source changes
   */
  modelChanged(e?: CustomEvent) {
    this.dataTree.dataSources = this.models.map(ds => getDataSourceClass(ds)) as IDataSourceModel[]
    // Remove all listeners
    this.models.forEach((dataSource: IDataSourceModel) => {
      dataSource.off(DATA_SOURCE_READY, this.dataSourceReadyBinded)
      dataSource.off(DATA_SOURCE_CHANGED, this.dataChangedBinded)
      dataSource.off(DATA_SOURCE_ERROR, this.dataSourceErrorBinded)
    })
    // Add listeners on all data sources
    this.models.forEach((dataSource: IDataSourceModel) => {
      dataSource.on(DATA_SOURCE_READY, this.dataSourceReadyBinded)
      dataSource.on(DATA_SOURCE_CHANGED, this.dataChangedBinded)
      dataSource.on(DATA_SOURCE_ERROR, this.dataSourceErrorBinded)
    })
    // Update undo manager
    this.editor.getModel().handleUpdates(this, this.toJSON())
    // Forward the event
    this.editor.trigger(DATA_SOURCE_CHANGED, e?.detail)
  }

  /**
   * Listen to data source changes
   */
  modelReady(ds: IDataSource) {
    // Forward the event
    this.editor.trigger(DATA_SOURCE_READY, ds)
  }

  getDataTree() {
    return this.dataTree
  }

  getPageQuery(page: Page): Record<DataSourceId, string> {
    const expressions = this.dataTree.getPageExpressions(page)
    return this.models
      .map(ds => {
        const dsExpressions = expressions
          // Resolve all states
          .map((componentExpression) => ({
            component: componentExpression.component,
            expression: componentExpression.expression.flatMap((token: StoredToken) => {
              switch(token.type) {
              case 'property':
              case 'filter':
                return token
              case 'state': {
                const resolved = this.dataTree.resolveState(token, componentExpression.component)
                if (!resolved) {
                  this.editor.runCommand('notifications:add', {
                    type: 'error',
                    group: NOTIFICATION_GROUP,
                    message: `Unable to resolve state ${JSON.stringify(token)}. State defined on component ${getComponentDebug(componentExpression.component)}`,
                    componentId: componentExpression.component.getId(),
                  })
                  throw new Error(`Unable to resolve state ${JSON.stringify(token)}. State defined on component ${getComponentDebug(componentExpression.component)}`)
                }
                return resolved
              }
              }
            })
          }))
          // Keep only the expressions for the current data source
          .filter(componentExpression => {
            const e = componentExpression.expression
            if(e.length === 0) return false
            // We resolved all states
            // An expression can not start with a filter
            // So this is a property
            const first = e[0] as Property
            // Keep only the expressions for the current data source
            return first.dataSourceId === ds.id
          })
        const trees = this.dataTree.toTrees(dsExpressions, ds.id)
        if(trees.length === 0) {
          return {
            dataSourceId: ds.id.toString(),
            query: '',
          }
        }
        const query = getDataSourceClass(ds).getQuery(trees)
        return {
          dataSourceId: ds.id.toString(),
          query,
        }
      })
      .filter(obj => !!obj.query)
      .reduce((acc, { dataSourceId, query }) => {
        acc[dataSourceId] = query
        return acc
      }, {} as Record<DataSourceId, string>)
  }
}
