import { Component, Env, mount } from "../../src/component/component";
import { useState } from "../../src/hooks";
import { xml } from "../../src/tags";
import { makeDeferred, makeTestEnv, makeTestFixture, nextTick, nextMicroTick } from "../helpers";
import { scheduler } from "../../src/component/scheduler";

//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------

// We create before each test:
// - fixture: a div, appended to the DOM, intended to be the target of dom
//   manipulations.  Note that it is removed after each test.
// - env: a WEnv, necessary to create new components

let fixture: HTMLElement;
let env: Env;

beforeEach(() => {
  fixture = makeTestFixture();
  env = makeTestEnv();
  Component.env = env;
});

afterEach(() => {
  fixture.remove();
});

describe("mount targets", () => {
  test("can attach a component to an existing node (if same tagname)", async () => {
    class App extends Component {
      static template = xml`<div t-att-class="state.customClass">app<p>another tag</p></div>`;
      state = useState({ customClass: "custom" });
    }
    const div = document.createElement("div");
    div.classList.add("arbitrary");
    div.innerHTML = `<p>pre-existing</p>`;
    fixture.appendChild(div);

    const app = await mount(App, { target: div, position: "self" });

    expect(fixture.innerHTML).toBe(
      `<div class="arbitrary custom"><p>pre-existing</p>app<p>another tag</p></div>`
    );
    expect(div).toBe(app.el);

    app.state.customClass = "custom2";
    await nextTick();
    expect(fixture.innerHTML).toBe(
      `<div class="arbitrary custom2"><p>pre-existing</p>app<p>another tag</p></div>`
    );
    expect(div).toBe(app.el);

    app.unmount();
    // This assert is a best guess
    // The use case it covers was not really thought through
    // and may change in the future
    expect(fixture.innerHTML).toBe("");
  });

  test("cannot attach a component to an existing node (if not same tagname)", async () => {
    class App extends Component {
      static template = xml`<span>app</span>`;
    }
    const div = document.createElement("div");
    fixture.appendChild(div);

    let error;
    try {
      await mount(App, { target: div, position: "self" });
    } catch (e) {
      error = e;
    }
    expect(error).toBeDefined();
    expect(error.message).toBe("Cannot attach 'App' to target node (not same tag name)");
  });

  test("can mount a component (with position='first-child')", async () => {
    class App extends Component {
      static template = xml`<div>app</div>`;
    }
    const span = document.createElement("span");
    fixture.appendChild(span);

    await mount(App, { target: fixture, position: "first-child" });
    expect(fixture.innerHTML).toBe("<div>app</div><span></span>");
  });

  test("can mount a component (with position='last-child')", async () => {
    class App extends Component {
      static template = xml`<div>app</div>`;
    }
    const span = document.createElement("span");
    fixture.appendChild(span);

    await mount(App, { target: fixture, position: "last-child" });
    expect(fixture.innerHTML).toBe("<span></span><div>app</div>");
  });

  test("default mount option is 'last-child'", async () => {
    class App extends Component {
      static template = xml`<div>app</div>`;
    }
    const span = document.createElement("span");
    fixture.appendChild(span);

    await mount(App, { target: fixture });
    expect(fixture.innerHTML).toBe("<span></span><div>app</div>");
  });
});

describe("unmounting and remounting", () => {
  test("widget can be unmounted and remounted", async () => {
    const steps: string[] = [];
    class MyWidget extends Component {
      static template = xml`<div>Hey</div>`;
      async willStart() {
        steps.push("willstart");
      }
      mounted() {
        steps.push("mounted");
      }
      willUnmount() {
        steps.push("willunmount");
      }
      patched() {
        throw new Error("patched should not be called");
      }
    }

    const w = await mount(MyWidget, { target: fixture });
    expect(fixture.innerHTML).toBe("<div>Hey</div>");
    expect(steps).toEqual(["willstart", "mounted"]);

    w.unmount();
    expect(fixture.innerHTML).toBe("");
    expect(steps).toEqual(["willstart", "mounted", "willunmount"]);

    await w.mount(fixture);
    expect(fixture.innerHTML).toBe("<div>Hey</div>");
    expect(steps).toEqual(["willstart", "mounted", "willunmount", "mounted"]);
  });

  test("widget can be mounted twice without ill effect", async () => {
    const steps: string[] = [];
    class MyWidget extends Component {
      static template = xml`<div>Hey</div>`;
      async willStart() {
        steps.push("willstart");
      }
      mounted() {
        steps.push("mounted");
      }
      willUnmount() {
        steps.push("willunmount");
      }
    }

    const w = await mount(MyWidget, { target: fixture });
    await w.mount(fixture);
    expect(fixture.innerHTML).toBe("<div>Hey</div>");
    expect(steps).toEqual(["willstart", "mounted"]);
  });

  test("state changes in willUnmount do not trigger rerender", async () => {
    const steps: string[] = [];

    class Child extends Component {
      static template = xml`
          <span><t t-esc="props.val"/><t t-esc="state.n"/></span>
        `;
      state = useState({ n: 2 });
      __render(f) {
        steps.push("render");
        return super.__render(f);
      }
      willPatch() {
        steps.push("willPatch");
      }
      patched() {
        steps.push("patched");
      }

      willUnmount() {
        steps.push("willUnmount");
        this.state.n = 3;
      }
    }
    class Parent extends Component {
      static template = xml`
          <div>
            <Child t-if="state.flag" val="state.val"/>
          </div>
        `;
      static components = { Child };
      state = useState({ val: 1, flag: true });
    }

    const widget = await mount(Parent, { target: fixture });
    expect(steps).toEqual(["render"]);
    expect(fixture.innerHTML).toBe("<div><span>12</span></div>");
    widget.state.flag = false;
    await nextTick();
    // we make sure here that no call to __render is done
    expect(steps).toEqual(["render", "willUnmount"]);
  });

  test("state changes in willUnmount will be applied on remount", async () => {
    class TestWidget extends Component {
      static template = xml`
          <div><t t-esc="state.val"/></div>
        `;
      state = useState({ val: 1 });
      willUnmount() {
        this.state.val = 3;
      }
    }

    const widget = new TestWidget();
    await widget.mount(fixture);
    expect(fixture.innerHTML).toBe("<div>1</div>");
    widget.unmount();
    expect(fixture.innerHTML).toBe("");
    await nextTick(); // wait for changes to be detected before remounting
    await widget.mount(fixture);
    expect(fixture.innerHTML).toBe("<div>3</div>");
    // we want to make sure that there are no remaining tasks left at this point.
    expect(Component.scheduler.tasks.length).toBe(0);
  });

  test("sub component is still active after being unmounted and remounted", async () => {
    class Child extends Component {
      static template = xml`
          <p t-on-click="state.value++">
            <t t-esc="state.value"/>
          </p>`;

      state = useState({ value: 1 });
    }

    class Parent extends Component {
      static components = { Child };
      static template = xml`<div><Child/></div>`;
    }

    const w = new Parent();
    await w.mount(fixture);
    expect(fixture.innerHTML).toBe("<div><p>1</p></div>");

    fixture.querySelector("p")!.click();
    await nextTick();
    expect(fixture.innerHTML).toBe("<div><p>2</p></div>");
    w.unmount();
    await nextTick();
    await w.mount(fixture);
    expect(fixture.innerHTML).toBe("<div><p>2</p></div>");
    fixture.querySelector("p")!.click();
    await nextTick();
    expect(fixture.innerHTML).toBe("<div><p>3</p></div>");
  });

  test("change state just before mounting component", async () => {
    const steps: number[] = [];
    class TestWidget extends Component {
      static template = xml`
          <div><t t-esc="state.val"/></div>
        `;
      state = useState({ val: 1 });
      __render(f) {
        steps.push(this.state.val);
        return super.__render(f);
      }
    }
    TestWidget.prototype.__render = jest.fn(TestWidget.prototype.__render);

    const widget = new TestWidget();
    widget.state.val = 2;
    await widget.mount(fixture);
    expect(fixture.innerHTML).toBe("<div>2</div>");
    expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(1);

    // unmount and re-mount, as in this case, willStart won't be called, so it's
    // slightly different
    widget.unmount();
    widget.state.val = 3;
    await widget.mount(fixture);
    expect(fixture.innerHTML).toBe("<div>3</div>");
    expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(2);
    expect(steps).toEqual([2, 3]);
  });

  test("change state while mounting component", async () => {
    const steps: number[] = [];
    class TestWidget extends Component {
      static template = xml`
          <div><t t-esc="state.val"/></div>
        `;
      state = useState({ val: 1 });
      __render(f) {
        steps.push(this.state.val);
        return super.__render(f);
      }
    }
    TestWidget.prototype.__render = jest.fn(TestWidget.prototype.__render);
    TestWidget.prototype.__patch = jest.fn(TestWidget.prototype.__patch);

    const widget = new TestWidget();
    let prom = widget.mount(fixture);
    widget.state.val = 2;
    await prom;
    expect(fixture.innerHTML).toBe("<div>2</div>");
    expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(1);

    // unmount and re-mount, as in this case, willStart won't be called, so it's
    // slightly different
    widget.unmount();
    prom = widget.mount(fixture);
    widget.state.val = 3;
    await prom;
    expect(fixture.innerHTML).toBe("<div>3</div>");
    expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(3);
    expect(TestWidget.prototype.__patch).toHaveBeenCalledTimes(2);
    expect(steps).toEqual([2, 2, 3]);
  });

  test("change state and render while mounted in detached dom", async () => {
    class App extends Component {
      static template = xml`<div><t t-esc="state.val"/></div>`;
      state = useState({ val: 1 });
    }

    const detachedDiv = document.createElement("div");
    const app = await mount(App, { target: detachedDiv });

    expect(detachedDiv.innerHTML).toBe("<div>1</div>");
    app.state.val = 2;
    await nextTick();
    expect(detachedDiv.innerHTML).toBe("<div>2</div>");
  });

  test("change state and render while not mounted ", async () => {
    class App extends Component {
      static template = xml`<div><t t-esc="state.val"/></div>`;
      state = useState({ val: 1 });
    }

    const app = new App(null);

    app.state.val = 2; // will call the render method (before being mounted)
    await nextTick();

    await app.mount(fixture);

    expect(fixture.innerHTML).toBe("<div>2</div>");
  });

  test("destroy and change state after mounted in detached dom", async () => {
    class App extends Component {
      static template = xml`<div><t t-esc="state.val"/></div>`;
      state = useState({ val: 1 });
    }

    const detachedDiv = document.createElement("div");
    const app = await mount(App, { target: detachedDiv });

    expect(detachedDiv.innerHTML).toBe("<div>1</div>");

    app.destroy();
    app.state.val = 2;
    await nextTick();
    expect(detachedDiv.innerHTML).toBe("");
  });

  test("change state while component is unmounted", async () => {
    let child;
    class Child extends Component {
      static template = xml`<span t-esc="state.val"/>`;
      state = useState({
        val: "C1",
      });
      constructor(parent, props) {
        super(parent, props);
        child = this;
      }
    }

    class Parent extends Component {
      static components = { Child };
      static template = xml`<div><t t-esc="state.val"/><Child/></div>`;
      state = useState({ val: "P1" });
    }

    const parent = new Parent();
    await parent.mount(fixture);
    expect(fixture.innerHTML).toBe("<div>P1<span>C1</span></div>");

    parent.unmount();
    expect(fixture.innerHTML).toBe("");

    parent.state.val = "P2";
    child.state.val = "C2";

    await parent.mount(fixture);
    expect(fixture.innerHTML).toBe("<div>P2<span>C2</span></div>");
  });

  test("change state while component is mounted in a fragment", async () => {
    class Child1 extends Component {
      static template = xml`<span>C1</span>`;
    }

    class Child2 extends Component {
      static template = xml`<span>C2</span>`;
    }

    class Parent extends Component {
      static components = { Child1, Child2 };
      static template = xml`
        <div>
          <Child1 t-if="child == 'c1'"/>
          <Child2 t-if="child == 'c2'"/>
        </div>`;
      child: string | false = false;
    }

    const fragment = document.createDocumentFragment();
    const parent = new Parent();
    await parent.mount(fragment);
    expect(parent.el.outerHTML).toBe("<div></div>");

    parent.child = "c1";
    parent.render();
    await Promise.resolve();

    parent.child = "c2";
    await parent.mount(fixture);

    expect(fixture.innerHTML).toBe("<div><span>C2</span></div>");
  });

  test("unmount component during a re-rendering", async () => {
    const def = makeDeferred();
    class Child extends Component {
      static template = xml`<span><t t-esc="props.val"/></span>`;
      willUpdateProps() {
        return def;
      }
    }
    Child.prototype.__render = jest.fn(Child.prototype.__render);

    class Parent extends Component {
      static template = xml`<div><Child val="state.val"/></div>`;
      static components = { Child };
      state = useState({ val: 1 });
    }

    const parent = new Parent();
    await parent.mount(fixture);
    expect(fixture.innerHTML).toBe("<div><span>1</span></div>");
    expect(Child.prototype.__render).toBeCalledTimes(1);

    parent.state.val = 2;
    await nextTick();
    expect(fixture.innerHTML).toBe("<div><span>1</span></div>");

    parent.unmount();
    expect(fixture.innerHTML).toBe("");

    def.resolve();
    await nextTick();
    expect(fixture.innerHTML).toBe("");
    expect(Child.prototype.__render).toBeCalledTimes(1);
  });

  test("widget can be mounted on different target", async () => {
    class MyWidget extends Component {
      static template = xml`<div>Hey</div>`;
      patched() {
        throw new Error("patched should not be called");
      }
    }
    const div = document.createElement("div");
    const span = document.createElement("span");
    fixture.appendChild(div);
    fixture.appendChild(span);
    const w = new MyWidget();
    await w.mount(div);

    expect(fixture.innerHTML).toBe("<div><div>Hey</div></div><span></span>");

    await w.mount(span);
    expect(fixture.innerHTML).toBe("<div></div><span><div>Hey</div></span>");
  });

  test("widget can be mounted on different target, another situation", async () => {
    const def = makeDeferred();
    const steps: string[] = [];

    class MyWidget extends Component {
      static template = xml`<div>Hey</div>`;
      async willStart() {
        return def;
      }
      patched() {
        throw new Error("patched should not be called");
      }
    }
    const div = document.createElement("div");
    const span = document.createElement("span");
    fixture.appendChild(div);
    fixture.appendChild(span);
    const w = new MyWidget();

    w.mount(div).catch(() => steps.push("1 catch"));

    await nextTick();
    expect(fixture.innerHTML).toBe("<div></div><span></span>");

    w.mount(span).then(() => steps.push("2 resolved"));

    // we wait two microticks because this is the number of internal promises
    // that need to be resolved/rejected, and because we want to prove here
    // that the first mount operation is cancelled immediately, and not after
    // one full tick.
    await nextMicroTick();
    await nextMicroTick();
    expect(steps).toEqual([]);
    await nextTick();
    expect(fixture.innerHTML).toBe("<div></div><span></span>");

    def.resolve();
    await nextTick();
    expect(steps).toEqual(["2 resolved"]);
    expect(fixture.innerHTML).toBe("<div></div><span><div>Hey</div></span>");
  });

  test("component can be mounted on same target, another situation", async () => {
    const def = makeDeferred();
    const steps: string[] = [];

    class MyWidget extends Component {
      static template = xml`<div>Hey</div>`;
      async willStart() {
        return def;
      }
      patched() {
        throw new Error("patched should not be called");
      }
    }
    const w = new MyWidget();

    w.mount(fixture).then(() => steps.push("1 resolved"));

    await nextTick();
    expect(fixture.innerHTML).toBe("");

    w.mount(fixture).then(() => steps.push("2 resolved"));

    await nextTick();
    expect(steps).toEqual([]);
    expect(fixture.innerHTML).toBe("");

    def.resolve();
    await nextTick();
    expect(fixture.innerHTML).toBe("<div>Hey</div>");
    expect(steps).toEqual(["1 resolved", "2 resolved"]);
  });

  test("mounting a destroyed widget", async () => {
    class MyWidget extends Component {
      static template = xml`<div>Hey</div>`;
    }
    const w = new MyWidget();
    w.destroy(); // because, why not

    let error;
    try {
      await w.mount(fixture);
    } catch (e) {
      error = e;
    }
    expect(scheduler.tasks.length).toBe(0);
    expect(error).toBeDefined();
    expect(error.message).toBe("Cannot mount a destroyed component");
  });

  test("destroying a sub-component cleans itself from parent's vnode", async () => {
    class C1 extends Component {
      static template = xml`<div><div><t t-esc="props.a"/></div></div>`;
    }
    class P extends Component {
      static components = { C1 };
      static template = xml`<div><div><C1 t-props="state" t-if="state.a"/></div></div>`;
      state = {
        a: "first",
      };
    }
    const parent = new P();
    await parent.mount(fixture);
    expect(fixture.textContent).toBe("first");
    parent.unmount();
    parent.state.a = "";
    parent.mount(fixture);
    parent.state.a = "fixed";
    await parent.render();
    expect(fixture.textContent).toBe("fixed");
  });

  test("destroying a sub-component cleans itself from parent's vnode, part 2", async () => {
    class C1 extends Component {
      static template = xml`<div><div><t t-esc="props.a"/></div></div>`;
    }
    class P extends Component {
      static components = { C1 };
      static template = xml`<div><div><C1 t-props="state" t-if="state.a"/>some text</div></div>`;
      state = {
        a: "first",
      };
    }
    const parent = new P();
    await parent.mount(fixture);
    expect(fixture.textContent).toBe("firstsome text");
    parent.unmount();
    parent.state.a = "";
    parent.mount(fixture);
    parent.state.a = "fixed";
    await parent.render();
    expect(fixture.textContent).toBe("fixedsome text");
  });

  test("destroying a sub-component cleans itself from parent's vnode, part 3", async () => {
    class C1 extends Component {
      static template = xml`<div><div><t t-esc="props.a"/></div></div>`;
    }

    class C2 extends Component {
      static template = xml`<C1 a="props.a"/>`;
      static components = { C1 };
    }

    class P extends Component {
      static components = { C2 };
      static template = xml`<div><div><C2 t-props="state" t-if="state.a"/></div></div>`;
      state = {
        a: "first",
      };
    }
    const parent = new P();
    await parent.mount(fixture);
    expect(fixture.textContent).toBe("first");
    parent.unmount();
    parent.state.a = "";
    parent.mount(fixture);
    parent.state.a = "fixed";
    await parent.render();
    expect(fixture.textContent).toBe("fixed");
  });

  test("destroying a sub-component cleans itself from parent's vnode, part 4", async () => {
    class C1 extends Component {
      static template = xml`<div><div><t t-esc="props.a"/></div></div>`;
    }

    class C2 extends Component {
      static template = xml`<C1 a="props.a"/>`;
      static components = { C1 };
    }
    class P extends Component {
      static components = { C2 };
      static template = xml`<div><div><C2 t-props="state" t-if="state.a"/>some text</div></div>`;
      state = {
        a: "first",
      };
    }
    const parent = new P();
    await parent.mount(fixture);
    expect(fixture.textContent).toBe("firstsome text");
    parent.unmount();
    parent.state.a = "";
    parent.mount(fixture);
    parent.state.a = "fixed";
    await parent.render();
    expect(fixture.textContent).toBe("fixedsome text");
  });

  test("remounting component tree where a component implement shouldupdate", async () => {
    let state: any;
    const steps = [];
    class Child extends Component {
      static template = xml`<div><t t-esc="state.word"/><t t-esc="props.name"/></div>`;

      state = useState({ word: "hello" });

      constructor(parent, props) {
        super(parent, props);
        state = this.state;
      }
      patched() {
        steps.push("patched");
      }
      mounted() {
        steps.push("mounted");
      }
      willUnmount() {
        steps.push("willUnmount");
      }
      shouldUpdate() {
        return false;
      }
    }

    class Parent extends Component {
      static template = xml`<div><Child name="state.name"/></div>`;
      static components = { Child };
      state = useState({ name: "World" });
    }

    const parent = await mount(Parent, { target: fixture });
    expect(fixture.innerHTML).toBe("<div><div>helloWorld</div></div>");

    parent.unmount();
    expect(fixture.innerHTML).toBe("");
    await parent.mount(fixture);
    expect(fixture.innerHTML).toBe("<div><div>helloWorld</div></div>");

    state.word = "test";

    await nextTick();
    expect(fixture.innerHTML).toBe("<div><div>testWorld</div></div>");
    expect(steps).toEqual(["mounted", "willUnmount", "mounted", "patched"]);
  });
});
