import { OpcodeJSON, UpdatingOpcode } from '../../opcodes';
import { VM, UpdatingVM } from '../../vm';
import * as Simple from '../../dom/interfaces';
import { FIX_REIFICATION } from '../../dom/interfaces';
import { Environment } from '../../environment';
import { FIXME, Option, Opaque, Dict, unwrap, expect } from '@glimmer/util';
import {
  CachedReference,
  Reference,
  ReferenceCache,
  RevisionTag,
  Revision,
  PathReference,
  combineTagged,
  isConst as isConstReference,
  isModified
} from '@glimmer/reference';
import { ModifierManager } from '../../modifier/interfaces';
import { NULL_REFERENCE, PrimitiveReference } from '../../references';
import { CompiledArgs, EvaluatedArgs } from '../../compiled/expressions/args';
import { AttributeManager } from '../../dom/attribute-managers';
import { ElementOperations } from '../../builder';
import { Assert } from './vm';
import { APPEND_OPCODES, OpcodeName as Op } from '../../opcodes';

APPEND_OPCODES.add(Op.Text, (vm, { op1: text }) => {
  vm.stack().appendText(vm.constants.getString(text));
});

APPEND_OPCODES.add(Op.Comment, (vm, { op1: text }) => {
  vm.stack().appendComment(vm.constants.getString(text));
});

APPEND_OPCODES.add(Op.OpenElement, (vm, { op1: tag }) => {
  vm.stack().openElement(vm.constants.getString(tag));
});

APPEND_OPCODES.add(Op.PushRemoteElement, vm => {
  let reference = vm.frame.getOperand<Simple.Element>();
  let cache = isConstReference(reference) ? undefined : new ReferenceCache(reference);
  let element = cache ? cache.peek() : reference.value();

  vm.stack().pushRemoteElement(element);

  if (cache) {
    vm.updateWith(new Assert(cache));
  }
});

APPEND_OPCODES.add(Op.PopRemoteElement, vm => vm.stack().popRemoteElement());

APPEND_OPCODES.add(Op.OpenComponentElement, (vm, { op1: _tag }) => {
  let tag = vm.constants.getString(_tag);
  vm.stack().openElement(tag, new ComponentElementOperations(vm.env));
});

APPEND_OPCODES.add(Op.OpenDynamicElement, vm => {
  let tagName = vm.frame.getOperand<string>().value();
  vm.stack().openElement(tagName);
});

class ClassList {
  private list: Option<Reference<string>[]> = null;
  private isConst = true;

  append(reference: Reference<string>) {
    let { list, isConst } = this;

    if (list === null) list = this.list = [];

    list.push(reference);
    this.isConst = isConst && isConstReference(reference);
  }

  toReference(): Reference<Option<string>> {
    let { list, isConst } = this;

    if (!list) return NULL_REFERENCE;

    if (isConst) return PrimitiveReference.create(toClassName(list));

    return new ClassListReference(list);
  }

}

class ClassListReference extends CachedReference<Option<string>> {
  public tag: RevisionTag;
  private list: Reference<string>[] = [];

  constructor(list: Reference<string>[]) {
    super();
    this.tag = combineTagged(list);
    this.list = list;
  }

  protected compute(): Option<string> {
    return toClassName(this.list);
  }
}

function toClassName(list: Reference<string>[]): Option<string> {
  let ret: Opaque[] = [];

  for (let i = 0; i < list.length; i++) {
    let value: FIXME<Opaque, 'use Opaque and normalize'> = list[i].value();
    if (value !== false && value !== null && value !== undefined) ret.push(value);
  }

  return (ret.length === 0) ? null : ret.join(' ');
}

export class SimpleElementOperations implements ElementOperations {
  private opcodes: Option<UpdatingOpcode[]> = null;
  private classList: Option<ClassList> = null;

  constructor(private env: Environment) {
  }

  addStaticAttribute(element: Simple.Element, name: string, value: string) {
    if (name === 'class') {
      this.addClass(PrimitiveReference.create(value));
    } else {
      this.env.getAppendOperations().setAttribute(element, name, value);
    }
  }

  addStaticAttributeNS(element: Simple.Element, namespace: string, name: string, value: string) {
    this.env.getAppendOperations().setAttribute(element, name, value, namespace);
  }

  addDynamicAttribute(element: Simple.Element, name: string, reference: PathReference<string>, isTrusting: boolean) {
    if (name === 'class') {
      this.addClass(reference);
    } else {
      let attributeManager = this.env.attributeFor(element, name, isTrusting);
      let attribute = new DynamicAttribute(element, attributeManager, name, reference);

      this.addAttribute(attribute);
    }
  }

  addDynamicAttributeNS(element: Simple.Element, namespace: Simple.Namespace, name: string, reference: PathReference<string>, isTrusting: boolean) {
    let attributeManager = this.env.attributeFor(element, name, isTrusting, namespace);
    let nsAttribute = new DynamicAttribute(element, attributeManager, name, reference, namespace);

    this.addAttribute(nsAttribute);
  }

  flush(element: Simple.Element, vm: VM) {
    let { env } = vm;
    let { opcodes, classList } = this;

    for (let i = 0; opcodes && i < opcodes.length; i++) {
      vm.updateWith(opcodes[i]);
    }

    if (classList) {
      let attributeManager = env.attributeFor(element, 'class', false);
      let attribute = new DynamicAttribute(element, attributeManager, 'class', classList.toReference());
      let opcode = attribute.flush(env);

      if (opcode) {
        vm.updateWith(opcode);
      }
    }

    this.opcodes = null;
    this.classList = null;
  }

  private addClass(reference: PathReference<string>) {
    let { classList } = this;

    if (!classList) {
      classList = this.classList = new ClassList();
    }

    classList.append(reference);
  }

  private addAttribute(attribute: Attribute) {
    let opcode = attribute.flush(this.env);

    if (opcode) {
      let { opcodes } = this;

      if (!opcodes) {
        opcodes = this.opcodes = [];
      }

      opcodes.push(opcode);
    }
  }
}

export class ComponentElementOperations implements ElementOperations {
  private attributeNames: Option<string[]> = null;
  private attributes: Option<Attribute[]> = null;
  private classList: Option<ClassList> = null;

  constructor(private env: Environment) {
  }

  addStaticAttribute(element: Simple.Element, name: string, value: string) {
    if (name === 'class') {
      this.addClass(PrimitiveReference.create(value));
    } else if (this.shouldAddAttribute(name)) {
      this.addAttribute(name, new StaticAttribute(element, name, value));
    }
  }

  addStaticAttributeNS(element: Simple.Element, namespace: string, name: string, value: string) {
    if (this.shouldAddAttribute(name)) {
      this.addAttribute(name, new StaticAttribute(element, name, value, namespace));
    }
  }

  addDynamicAttribute(element: Simple.Element, name: string, reference: PathReference<string>, isTrusting: boolean) {
    if (name === 'class') {
      this.addClass(reference);
    } else if (this.shouldAddAttribute(name)) {
      let attributeManager = this.env.attributeFor(element, name, isTrusting);
      let attribute = new DynamicAttribute(element, attributeManager, name, reference);

      this.addAttribute(name, attribute);
    }
  }

  addDynamicAttributeNS(element: Simple.Element, namespace: Simple.Namespace, name: string, reference: PathReference<string>, isTrusting: boolean) {
    if (this.shouldAddAttribute(name)) {
      let attributeManager = this.env.attributeFor(element, name, isTrusting, namespace);
      let nsAttribute = new DynamicAttribute(element, attributeManager, name, reference, namespace);

      this.addAttribute(name, nsAttribute);
    }
  }

  flush(element: Simple.Element, vm: VM) {
    let { env } = this;
    let { attributes, classList } = this;

    for (let i = 0; attributes && i < attributes.length; i++) {
      let opcode = attributes[i].flush(env);

      if (opcode) {
        vm.updateWith(opcode);
      }
    }

    if (classList) {
      let attributeManager = env.attributeFor(element, 'class', false);
      let attribute = new DynamicAttribute(element, attributeManager, 'class', classList.toReference());
      let opcode = attribute.flush(env);

      if (opcode) {
        vm.updateWith(opcode);
      }
    }
  }

  private shouldAddAttribute(name: string): boolean {
    return !this.attributeNames || this.attributeNames.indexOf(name) === -1;
  }

  private addClass(reference: PathReference<string>) {
    let { classList } = this;

    if (!classList) {
      classList = this.classList = new ClassList();
    }

    classList.append(reference);
  }

  private addAttribute(name: string, attribute: Attribute) {
    let { attributeNames, attributes } = this;

    if (!attributeNames) {
      attributeNames = this.attributeNames = [];
      attributes = this.attributes = [];
    }

    attributeNames.push(name);
    unwrap(attributes).push(attribute);
  }
}

APPEND_OPCODES.add(Op.FlushElement, vm => {
  let stack = vm.stack();

  let action = 'FlushElementOpcode#evaluate';
  stack.expectOperations(action).flush(stack.expectConstructing(action), vm);
  stack.flushElement();
});

APPEND_OPCODES.add(Op.CloseElement, vm => vm.stack().closeElement());

APPEND_OPCODES.add(Op.PopElement, vm => vm.stack().popElement());

APPEND_OPCODES.add(Op.StaticAttr, (vm, { op1: _name, op2: _value, op3: _namespace }) => {
  let name = vm.constants.getString(_name);
  let value = vm.constants.getString(_value);

  if (_namespace) {
    let namespace = vm.constants.getString(_namespace);
    vm.stack().setStaticAttributeNS(namespace, name, value);
  } else {
    vm.stack().setStaticAttribute(name, value);
  }
});

APPEND_OPCODES.add(Op.Modifier, (vm, { op1: _name, op2: _manager, op3: _args }) => {
  let manager = vm.constants.getOther<ModifierManager<Opaque>>(_manager);
  let rawArgs = vm.constants.getExpression<CompiledArgs>(_args);
  let stack = vm.stack();
  let { constructing: element, updateOperations } = stack;
  let args = rawArgs.evaluate(vm);
  let dynamicScope = vm.dynamicScope();
  let modifier = manager.create(element as FIX_REIFICATION<Element>, args, dynamicScope, updateOperations);

  vm.env.scheduleInstallModifier(modifier, manager);
  let destructor = manager.getDestructor(modifier);

  if (destructor) {
    vm.newDestroyable(destructor);
  }

  vm.updateWith(new UpdateModifierOpcode(
    manager,
    modifier,
    args
  ));
});

export class UpdateModifierOpcode extends UpdatingOpcode {
  public type = "update-modifier";
  private lastUpdated: Revision;

  constructor(
    private manager: ModifierManager<Opaque>,
    private modifier: Opaque,
    private args: EvaluatedArgs
  ) {
    super();
    this.tag = args.tag;
    this.lastUpdated = args.tag.value();
  }

  evaluate(vm: UpdatingVM) {
    let { manager, modifier, tag, lastUpdated } = this;

    if (!tag.validate(lastUpdated)) {
      vm.env.scheduleUpdateModifier(modifier, manager);
      this.lastUpdated = tag.value();
    }
  }

  toJSON(): OpcodeJSON {
    return {
      guid: this._guid,
      type: this.type,
      args: [JSON.stringify(this.args)]
    };
  }
}

export interface Attribute {
  name: string;
  flush(env: Environment): Option<UpdatingOpcode>;
}

export class StaticAttribute implements Attribute {
  constructor(
    private element: Simple.Element,
    public name: string,
    private value: string,
    private namespace?: string
  ) {}

  flush(env: Environment): Option<UpdatingOpcode> {
    env.getAppendOperations().setAttribute(this.element, this.name, this.value, this.namespace);
    return null;
  }
}

export class DynamicAttribute implements Attribute  {
  private cache: Option<ReferenceCache<Opaque>> = null;

  public tag: RevisionTag;

  constructor(
    private element: Simple.Element,
    private attributeManager: AttributeManager,
    public name: string,
    private reference: Reference<Opaque>,
    private namespace?: Simple.Namespace
  ) {
    this.tag = reference.tag;
  }

  patch(env: Environment) {
    let { element, cache } = this;

    let value = expect(cache, 'must patch after flush').revalidate();

    if (isModified(value)) {
      this.attributeManager.updateAttribute(env, element as FIXME<Element, 'needs to be reified properly'>, value, this.namespace);
    }
  }

  flush(env: Environment): Option<UpdatingOpcode> {
    let { reference, element } = this;

    if (isConstReference(reference)) {
      let value = reference.value();
      this.attributeManager.setAttribute(env, element, value, this.namespace);
      return null;
    } else {
      let cache = this.cache = new ReferenceCache(reference);
      let value = cache.peek();
      this.attributeManager.setAttribute(env, element, value, this.namespace);
      return new PatchElementOpcode(this);
    }
  }

  toJSON(): Dict<Option<string>> {
    let { element, namespace, name, cache } = this;

    let formattedElement = formatElement(element);
    let lastValue = expect(cache, 'must serialize after flush').peek() as string;

    if (namespace) {
      return {
        element: formattedElement,
        type: 'attribute',
        namespace,
        name,
        lastValue
      };
    }

    return {
      element: formattedElement,
      type: 'attribute',
      namespace: namespace === undefined ? null : namespace,
      name,
      lastValue
    };
  }
}

function formatElement(element: Simple.Element): string {
  return JSON.stringify(`<${element.tagName.toLowerCase()} />`);
}

APPEND_OPCODES.add(Op.DynamicAttrNS, (vm, { op1: _name, op2: _namespace, op3: trusting }) => {
  let name = vm.constants.getString(_name);
  let namespace = vm.constants.getString(_namespace);
  let reference = vm.frame.getOperand<string>();
  vm.stack().setDynamicAttributeNS(namespace, name, reference, !!trusting);
});

APPEND_OPCODES.add(Op.DynamicAttr, (vm, { op1: _name, op2: trusting }) => {
  let name = vm.constants.getString(_name);
  let reference = vm.frame.getOperand<string>();
  vm.stack().setDynamicAttribute(name, reference, !!trusting);
});

export class PatchElementOpcode extends UpdatingOpcode {
  public type = "patch-element";

  private operation: DynamicAttribute;

  constructor(operation: DynamicAttribute) {
    super();
    this.tag = operation.tag;
    this.operation = operation;
  }

  evaluate(vm: UpdatingVM) {
    this.operation.patch(vm.env);
  }

  toJSON(): OpcodeJSON {
    let { _guid, type, operation } = this;

    return {
      guid: _guid,
      type,
      details: operation.toJSON()
    };
  }
}
