import {FormView} from 'app/client/components/Forms/FormView';
import * as elements from 'app/client/components/Forms/elements';
import {
  bundleChanges, Computed, Disposable, dom, DomContents,
  MultiHolder, MutableObsArray, obsArray, Observable
} from 'grainjs';
import {v4 as uuidv4} from 'uuid';


export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field';

/**
 * Box model is a JSON that represents a form element. Every element can be converted to this element and every
 * ViewModel should be able to read it and built itself from it.
 */
export interface Box extends Record<string, any> {
  type: BoxType,
  children?: Array<Box>,
}

/**
 * A place where to insert a box.
 */
export type Place = (box: Box) => BoxModel;

/**
 * View model constructed from a box JSON structure.
 */
export abstract class BoxModel extends Disposable {

  /**
   * A factory method that creates a new BoxModel from a Box JSON by picking the right class based on the type.
   */
  public static new(box: Box, parent: BoxModel | null, view: FormView | null = null): BoxModel {
    const subClassName = `${box.type.split(':')[0]}Model`;
    const factories = elements as any;
    const factory = factories[subClassName];
    // If we have a factory, use it.
    if (factory) {
      return new factory(box, parent, view || parent!.view);
    }
    // Otherwise, use the default.
    return new DefaultBoxModel(box, parent, view || parent!.view);
  }

  /**
   * The id of the created box. The value here is not important. It is only used as a plain old pointer to this
   * element. Every new box will get a new id in constructor. Even if this is the same box as before. We just need
   * it as box are serialized to JSON and put into clipboard, and we need to be able to find them back.
   */
  public id: string;
  /**
   * Type of the box. As the type is bounded to the class that is used to render the box, it is possible
   * to change the type of the box just by changing this value. The box is then replaced in the parent.
   */
  public type: Observable<string>;
  /**
   * List of children boxes.
   */
  public children: MutableObsArray<BoxModel>;
  /**
   * Any other dynamically added properties (that are not concrete fields in the derived classes)
   */
  public props: Record<string, Observable<any>> = {};
  /**
   * Publicly exposed state if the element was just cut.
   * TODO: this should be moved to FormView, as this model doesn't care about that.
   */
  public cut = Observable.create(this, false);

  /**
   * Don't use it directly, use the BoxModel.new factory method instead.
   */
  constructor(box: Box, public parent: BoxModel | null, public view: FormView) {
    super();

    // Store "pointer" to this element.
    this.id = uuidv4();

    // Create observables for all properties.
    this.type = Observable.create(this, box.type);
    this.children = this.autoDispose(obsArray([]));

    // And now update this and all children based on the box JSON.
    bundleChanges(() => {
      this.update(box);
    });

    // Some boxes need to do some work after initialization, so we call this method.
    // Of course, they also can override the constructor, but this is a bit easier.
    this.onCreate();
  }

  /**
   * Public method that should be called when this box is dropped somewhere. In derived classes
   * this method can send some actions to the server, or do some other work. In particular Field
   * will insert or reveal a column.
   */
  public async onDrop() {

  }

  /**
   * The only method that derived classes need to implement. It should return a DOM element that
   * represents this box.
   */
  public abstract render(context: RenderContext): HTMLElement;


  public removeChild(box: BoxModel) {
    const myIndex = this.children.get().indexOf(box);
    if (myIndex < 0) { throw new Error('Cannot remove box that is not in parent'); }
    this.children.splice(myIndex, 1);
  }

  /**
   * Remove self from the parent without saving.
   */
  public removeSelf() {
    this.parent?.removeChild(this);
  }

  /**
   * Remove self from the parent and save. Use to bundle layout save with any other changes.
   * See Fields for the implementation.
   * TODO: this is needed as action bundling is very limited.
   */
  public async deleteSelf() {
    const parent = this.parent;
    this.removeSelf();
    await parent!.save();
  }

  /**
   * Cuts self and puts it into clipboard.
   */
  public async cutSelf() {
    [...this.root().list()].forEach(box => box?.cut.set(false));
    // Add this box as a json to clipboard.
    await navigator.clipboard.writeText(JSON.stringify(this.toJSON()));
    this.cut.set(true);
  }

  /**
   * Accepts box from clipboard and inserts it before this box or if this is a container box, then
   * as a first child. Default implementation is to insert before self.
   */
  public drop(dropped: Box) {
    // Get the box that was dropped.
    if (!dropped) { return null; }
    if (dropped.id === this.id) {
      return null;
    }
    // We need to remove it from the parent, so find it first.
    const droppedId = dropped.id;
    const droppedRef = this.root().find(droppedId);
    if (droppedRef) {
      droppedRef.removeSelf();
    }
    return this.placeBeforeMe()(dropped);
  }

  public prop(name: string, defaultValue?: any) {
    if (!this.props[name]) {
      this.props[name] = Observable.create(this, defaultValue ?? null);
    }
    return this.props[name];
  }

  public async save(): Promise<void> {
    if (!this.parent) { throw new Error('Cannot save detached box'); }
    return this.parent.save();
  }

  /**
   * Replaces children at index.
   */
  public replaceAtIndex(box: Box, index: number) {
    const newOne = BoxModel.new(box, this);
    this.children.splice(index, 1, newOne);
    return newOne;
  }

  public append(box: Box) {
    const newOne = BoxModel.new(box, this);
    this.children.push(newOne);
    return newOne;
  }

  public insert(box: Box, index: number) {
    const newOne = BoxModel.new(box, this);
    this.children.splice(index, 0, newOne);
    return newOne;
  }


  /**
   * Replaces existing box with a new one, whenever it is found.
   */
  public replace(existing: BoxModel, newOne: Box|BoxModel) {
    const index = this.children.get().indexOf(existing);
    if (index < 0) { throw new Error('Cannot replace box that is not in parent'); }
    const model = newOne instanceof BoxModel ? newOne : BoxModel.new(newOne, this);
    model.parent = this;
    model.view = this.view;
    this.children.splice(index, 1, model);
    return model;
  }

  /**
   * Creates a place to insert a box before this box.
   */
  public placeBeforeFirstChild() {
    return (box: Box) => this.insert(box, 0);
  }

  // Some other places.
  public placeAfterListChild() {
    return (box: Box) => this.insert(box, this.children.get().length);
  }

  public placeAt(index: number) {
    return (box: Box) => this.insert(box, index);
  }

  public placeAfterChild(child: BoxModel) {
    return (box: Box) => this.insert(box, this.children.get().indexOf(child) + 1);
  }

  public placeAfterMe() {
    return this.parent!.placeAfterChild(this);
  }

  public placeBeforeMe() {
    return this.parent!.placeAt(this.parent!.children.get().indexOf(this));
  }

  public insertAfter(json: any) {
    return this.parent!.insert(json, this.parent!.children.get().indexOf(this) + 1);
  }

  public insertBefore(json: any) {
    return this.parent!.insert(json, this.parent!.children.get().indexOf(this));
  }

  public root() {
    let root: BoxModel = this;
    while (root.parent) { root = root.parent; }
    return root;
  }

  /**
   * Finds a box with a given id in the tree.
   */
  public find(droppedId: string): BoxModel | null {
    for (const child of this.kids()) {
      if (child.id === droppedId) { return child; }
      const found = child.find(droppedId);
      if (found) { return found; }
    }
    return null;
  }

  public kids() {
    return this.children.get().filter(Boolean);
  }

  /**
   * The core responsibility of this method is to update this box and all children based on the box JSON.
   * This is counterpart of the FloatingRowModel, that enables this instance to point to a different box.
   */
  public update(boxDef: Box) {
    // If we have a type and the type is changed, then we need to replace the box.
    if (this.type.get() && boxDef.type !== this.type.get()) {
      this.parent!.replace(this, BoxModel.new(boxDef, this.parent));
      return;
    }

    // Update all properties of self.
    for (const key in boxDef) {
      if (key === 'id' || key === 'type' || key === 'children') { continue; }
      if (!boxDef.hasOwnProperty(key)) { continue; }
      if (this.prop(key).get() === boxDef[key]) { continue; }
      this.prop(key).set(boxDef[key]);
    }

    // Add or delete any children that were removed or added.
    const myLength = this.children.get().length;
    const newLength = boxDef.children ? boxDef.children.length : 0;
    if (myLength > newLength) {
      this.children.splice(newLength, myLength - newLength);
    } else if (myLength < newLength) {
      for (let i = myLength; i < newLength; i++) {
        const toPush = boxDef.children![i];
        this.children.push(toPush && BoxModel.new(toPush, this));
      }
    }

    // Update those that indices are the same.
    const min = Math.min(myLength, newLength);
    for (let i = 0; i < min; i++) {
      const atIndex = this.children.get()[i];
      const atIndexDef = boxDef.children![i];
      atIndex.update(atIndexDef);
    }
  }

  /**
   * Serialize this box to JSON.
   */
  public toJSON(): Box {
    return {
      id: this.id,
      type: this.type.get() as BoxType,
      children: this.children.get().map(child => child?.toJSON() || null),
      ...(Object.fromEntries(Object.entries(this.props).map(([key, val]) => [key, val.get()]))),
    };
  }

  public * list(): IterableIterator<BoxModel> {
    for (const child of this.kids()) {
      yield child;
      yield* child.list();
    }
  }

  protected onCreate() {

  }
}

export class LayoutModel extends BoxModel {
  constructor(box: Box, public parent: BoxModel | null, public _save: () => Promise<void>, public view: FormView) {
    super(box, parent, view);
  }

  public async save() {
    return await this._save();
  }

  public render(): HTMLElement {
    throw new Error('Method not implemented.');
  }
}

class DefaultBoxModel extends BoxModel {
  public render(): HTMLElement {
    return dom('div', `Unknown box type ${this.type.get()}`);
  }
}

export interface RenderContext {
  overlay: Observable<boolean>,
}

export type Builder = (owner: MultiHolder, options: {
  box: BoxModel,
  view: FormView,
  overlay: Observable<boolean>,
}) => DomContents;

export const ignoreClick = dom.on('click', (ev) => {
  ev.stopPropagation();
  ev.preventDefault();
});

export function unwrap<T>(val: T | Computed<T>): T {
  return val instanceof Computed ? val.get() : val;
}

export function parseBox(text: string) {
  try {
    const json = JSON.parse(text);
    return json && typeof json === 'object' && json.type ? json : null;
  } catch (e) {
    return null;
  }
}
