import { vi } from 'vitest';
import { Riba, View, Binder } from "./index.js";
import { Data } from "../spec/lib/moch.data.js";
import { dotAdapter } from "./adapters/index.js";
import {
  TextBinder,
  HtmlBinder,
  EachStarBinder,
  ValueBinder,
  AddClassBinder,
} from "./binders/index.js";
import { Formatter, Adapter } from "./types/index.js";

describe("riba.Binder", () => {
  const riba = new Riba();
  riba.module.adapter.register(dotAdapter);
  riba.module.binder.register(TextBinder);
  riba.module.binder.register(HtmlBinder);
  riba.module.binder.register(ValueBinder);
  riba.module.binder.register(EachStarBinder);
  riba.module.binder.register(AddClassBinder);

  let model: object;
  let el: HTMLElement;
  let view: View;
  let binding: TextBinder | ValueBinder;
  let originalPrefix: string[];
  let adapter: Adapter;
  let routineFn;

  beforeEach(() => {
    originalPrefix = riba.prefix;
    riba.prefix = ["data"];
    adapter = riba.adapters["."];

    el = document.createElement("div");
    el.setAttribute("data-text", "obj.name");

    view = riba.bind(el, { obj: { name: "test" } });
    binding = view.bindings[0] as TextBinder;
    model = binding.model;
  });

  afterEach(() => {
    riba.prefix = originalPrefix;
  });

  it("gets assigned the proper binder routine matching the identifier", () => {
    routineFn = binding.name;
    expect(routineFn).toEqual("text");
    expect(routineFn).toEqual(riba.binders.text.key);
  });

  describe("when bind to non configurable properties", () => {
    let data: any;
    beforeEach(() => {
      data = {
        name: "x",
        items: [],
      };
      Object.defineProperty(data, "name", {
        enumerable: true,
        configurable: false,
        writable: true,
        value: "y",
      });
      el.setAttribute("data-show", "obj.items.length");
    });

    it("does not throw", () => {
      expect(() => {
        riba.bind(el, { obj: data });
      }).not.toThrow();
    });
  });

  describe("with multiple prefixes", () => {
    beforeEach(() => {
      riba.prefix = ["foo", "bar", "any"];

      // Positive test
      el.setAttribute("foo-a", "'a'");
      el.setAttribute("bar-b", "'b'");
      el.setAttribute("any-c", "'c'");

      // Negative test
      el.setAttribute("rv-d", "'d'");
    });

    it("All prefixes should be applied", () => {
      expect(() => {
        riba.bind(el, {});
      }).not.toThrow();

      // Positive test
      expect(el.getAttribute("a")).toEqual("a");
      expect(el.getAttribute("b")).toEqual("b");
      expect(el.getAttribute("c")).toEqual("c");

      // Negative test
      expect(el.getAttribute("d")).toBeNull();
    });
  });

  describe("with formatters", () => {
    let valueInput: HTMLInputElement;

    beforeEach(() => {
      const awesomeFormatter = {
        name: "awesome",
        read: (value) => "awesome " + value,
      } as Formatter;

      const totallyFormatter = {
        name: "totally",
        read: (value, prefix) => prefix + " totally " + value,
      } as Formatter;

      const placeholderFormatter = {
        name: "and",
        read: (value, affix) => value + affix,
      } as Formatter;

      riba.module.formatter.register(awesomeFormatter);
      riba.module.formatter.register(totallyFormatter);
      riba.module.formatter.register(placeholderFormatter, "and", true);
      riba.module.formatter.register(placeholderFormatter, "radical", true);
      riba.module.formatter.register(placeholderFormatter, "totally", true);
    });

    it("register all formatters", () => {
      valueInput = document.createElement("input");
      valueInput.setAttribute("type", "text");
      valueInput.setAttribute(
        "data-value",
        "obj.name | awesome | radical | totally"
      );

      view = riba.bind(valueInput, { obj: { name: "nothing" } });
      binding = view.bindings[0] as TextBinder;
      expect(binding.formatters).toEqual(["awesome", "radical", "totally"]);
    });

    it("allows arguments with pipes", () => {
      valueInput = document.createElement("input");
      valueInput.setAttribute("type", "text");
      valueInput.setAttribute(
        "data-value",
        `obj.name | awesome | totally 'arg | with || pipes' 'and more args' | and 'others formatters' with 'pi||pes'`
      );

      view = riba.bind(valueInput, { obj: { name: "nothing" } });
      binding = view.bindings[0] as TextBinder;

      expect(binding.formatters).toEqual([
        "awesome",
        `totally 'arg | with || pipes' 'and more args'`,
        `and 'others formatters' with 'pi||pes'`,
      ]);
    });

    it("cleans formatter subscriptions on unbind", () => {
      let cleanupCalls = 0;
      const cleanupFormatter = {
        name: "cleanup-test",
        read: (value: string, context: any) => {
          context?.addCleanup(() => {
            cleanupCalls++;
          }, "subscription");
          return value;
        },
      } as Formatter;
      riba.module.formatter.register(cleanupFormatter);

      const cleanupEl = document.createElement("div");
      cleanupEl.setAttribute("data-text", "obj.name | cleanup-test");
      const cleanupView = riba.bind(cleanupEl, { obj: { name: "test" } });
      const cleanupBinding = cleanupView.bindings[0] as TextBinder;

      // Re-sync replaces the old cleanup and calls it once.
      cleanupBinding.sync();
      expect(cleanupCalls).toBe(1);

      cleanupBinding._unbind();
      expect(cleanupCalls).toBe(2);
    });

    it("supports formatter-triggered invalidation without model changes", async () => {
      let suffix = "a";
      const listeners = new Set<() => void>();
      const emitter = {
        on(_eventName: string, callback: () => void) {
          listeners.add(callback);
        },
        off(_eventName: string, callback: () => void) {
          listeners.delete(callback);
        },
        emit() {
          listeners.forEach((listener) => listener());
        },
      };

      const reactiveFormatter = {
        name: "reactive-test",
        read: (value: string, context: any) => {
          if (context) {
            context.on(
              emitter,
              "changed",
              () => context.invalidate(),
              "changed-subscription",
            );
          }
          return `${value}-${suffix}`;
        },
      } as Formatter;
      riba.module.formatter.register(reactiveFormatter);

      const reactiveEl = document.createElement("div");
      reactiveEl.setAttribute("data-text", "obj.name | reactive-test");
      const reactiveView = riba.bind(reactiveEl, { obj: { name: "test" } });
      expect(reactiveEl.textContent).toBe("test-a");

      suffix = "b";
      emitter.emit();
      await new Promise((resolve) => setTimeout(resolve, 0));
      expect(reactiveEl.textContent).toBe("test-b");

      reactiveView.unbind();
      expect(listeners.size).toBe(0);
    });
  });

  describe("bind()", () => {
    it("subscribes to the model for changes via the adapter", () => {
      vi.spyOn(adapter, "observe");
      binding._bind();
      expect(adapter.observe).toHaveBeenCalledWith(model, "name", binding);
    });

    it(`calls the binder's bind method if one exists`, () => {
      expect(() => {
        binding._bind();
      }).not.toThrow();

      binding.bind = () => {
        return;
      };
      vi.spyOn(binding, "bind" as any);

      binding._bind();
      expect(binding.bind).toHaveBeenCalled();
    });

    describe("with preloadData set to true", () => {
      beforeEach(() => {
        riba.preloadData = true;
      });

      it("sets the initial value", () => {
        vi.spyOn(binding, "set");
        binding._bind();
        expect(binding.set).toHaveBeenCalledWith("test");
      });
    });
  });

  describe("unbind()", () => {
    describe("without a binder.unbind defined", () => {
      it("should not throw an error", () => {
        expect(() => {
          binding._unbind();
        }).not.toThrow();
      });
    });

    describe("with a binder.unbind defined", () => {
      beforeEach(() => {
        binding.unbind = () => {
          /**/
        };
      });

      it("should not throw an error", () => {
        expect(() => {
          binding._unbind();
        }).not.toThrow();
      });

      it(`calls the binder's unbind method`, () => {
        vi.spyOn(binding, "unbind" as any);
        binding._unbind();
        expect(binding.unbind).toHaveBeenCalled();
      });
    });
  });

  describe("set()", () => {
    it("performs the binding routine with the supplied value", () => {
      routineFn = vi.spyOn(binding, "routine");

      binding.set("sweater");
      expect(routineFn).toHaveBeenCalledWith(el, "sweater");
    });

    it("applies any formatters to the value before performing the routine", () => {
      if (!view.options.formatters) {
        throw new Error("formatters are undefined!");
      }
      routineFn = vi.spyOn(binding, "routine");
      view.options.formatters.awesome = {
        name: "awesome",
        read(value: string) {
          return "awesome " + value;
        },
      };

      if (binding.formatters) {
        binding.formatters.push("awesome");
      }
      binding.set("sweater");

      expect(binding.routine).toHaveBeenCalledWith(el, "awesome sweater");
    });
  });

  describe("publish()", () => {
    let numberInput: HTMLInputElement;
    it(`should publish the value of a number input`, () => {
      numberInput = document.createElement("input");
      numberInput.setAttribute("type", "number");
      numberInput.setAttribute("data-value", "obj.num");

      view = riba.bind(numberInput, { obj: { num: 42 } });
      binding = view.bindings[0] as TextBinder;
      model = binding.model;

      numberInput.value = "42";

      vi.spyOn(adapter, "set");
      binding.publish();
      expect(adapter.set).toHaveBeenCalledWith(model, "num", "42");
    });
  });

  describe("publishTwoWay()", () => {
    let numberInput;
    let valueInput;
    it("applies a two-way read formatter to function same as a single-way", () => {
      const awesomeFormatter = {
        read: (value) => "awesome " + value,
      } as Formatter;

      riba.module.formatter.register(awesomeFormatter, "awesome");

      routineFn = vi.spyOn(binding, "routine");

      if (!binding.formatters) {
        throw new Error("Formatters not set!");
      }

      binding.formatters.push("awesome");

      binding.set("sweater");
      expect(routineFn).toHaveBeenCalledWith(el, "awesome sweater");
    });

    it(`should publish the value of a number input`, () => {
      // TODO remove any
      (riba.formatters.awesome as any) = {
        publish: (value: string) => "awesome " + value,
      };

      numberInput = document.createElement("input");
      numberInput.setAttribute("type", "number");
      numberInput.setAttribute("data-value", "obj.num | awesome");

      view = riba.bind(numberInput, { obj: { num: 42 } });
      binding = view.bindings[0] as TextBinder;
      model = binding.model;

      numberInput.value = "42";

      binding.publish();
      expect(adapter.set).toHaveBeenCalledWith(model, "num", "awesome 42");
    });

    it(`should format a value in both directions`, () => {
      // TODO remove any
      (riba.formatters.awesome as any) = {
        publish: (value: string) => "awesome " + value,
        read: (value: string) => value + " is awesome",
      };

      vi.spyOn(binding, "routine");

      valueInput = document.createElement("input");
      valueInput.setAttribute("type", "text");
      valueInput.setAttribute("data-value", "obj.name | awesome");

      view = riba.bind(valueInput, { obj: { name: "nothing" } });
      binding = view.bindings[0] as ValueBinder;
      model = binding.model;

      vi.spyOn(binding, "routine");

      valueInput.value = "charles";
      binding.publish();

      expect(binding.name).toEqual("value");

      expect(adapter.set).toHaveBeenCalledWith(
        model,
        "name",
        "awesome charles"
      );

      binding.set("fred");
      expect(binding.routine).toHaveBeenCalledWith(
        valueInput,
        "fred is awesome"
      );
    });

    it(`should resolve formatter arguments to their values`, () => {
      (riba.formatters.withArguments as Formatter) = {
        name: "withArguments",
        publish: (value, arg1, arg2) => {
          return value + ":" + arg1 + ":" + arg2;
        },
        read: (value, arg1, arg2) => {
          return value.replace(":" + arg1 + ":" + arg2, "");
        },
      };

      valueInput = document.createElement("input");
      valueInput.setAttribute("type", "text");
      valueInput.setAttribute(
        "data-value",
        `obj.name | withArguments config.age 'male'`
      );

      view = riba.bind(valueInput, {
        obj: {
          name: "nothing",
        },
        config: {
          age: 50,
        },
      });

      binding = view.bindings[0] as ValueBinder;
      vi.spyOn(binding, "routine");
      model = binding.model;

      expect(binding.name).toEqual("value");

      valueInput.value = "bobby";
      binding.publish();
      expect(adapter.set).toHaveBeenCalledWith(model, "name", "bobby:50:male");

      expect(valueInput.value).toEqual("bobby");

      binding.set("andy:50:male");
      expect(binding.routine).toHaveBeenCalledWith(valueInput, "andy");
    });

    it(`should resolve formatter arguments correctly with multiple formatters`, () => {
      (riba.formatters.wrap as Formatter) = {
        name: "wrap",
        publish: (value: string, arg1: string, arg2: string) => {
          return arg1 + value + arg2;
        },
        read: (value: string, arg1: string, arg2: string) => {
          return value.replace(arg1, "").replace(arg2, "");
        },
      };

      (riba.formatters.saveAsCase as Formatter) = {
        name: "saveAsCase",
        publish: (value, typeCase) => {
          return value["to" + typeCase + "Case"]();
        },
        read: (value, typeCase) => {
          return value[typeCase === "Upper" ? "toLowerCase" : "toUpperCase"]();
        },
      };

      valueInput = document.createElement("input");
      valueInput.setAttribute("type", "text");
      valueInput.setAttribute(
        "data-value",
        `obj.name | saveAsCase config.typeCase | wrap config.curly '}' | wrap config.square ']' | wrap config.paren ')'`
      );

      view = riba.bind(valueInput, {
        obj: {
          name: "nothing",
        },
        config: {
          paren: "(",
          square: "[",
          curly: "{",
          typeCase: "Upper",
        },
      });

      binding = view.bindings[0] as TextBinder;
      model = binding.model;

      vi.spyOn(binding, "routine");

      valueInput.value = "bobby";
      binding.publish();
      expect(adapter.set).toHaveBeenCalledWith(model, "name", "{[(BOBBY)]}");

      expect(valueInput.value).toEqual("bobby");

      binding.set("{[(ANDY)]}");
      expect(binding.routine).toHaveBeenCalledWith(valueInput, "andy");
    });

    it(`should not fail or format if the specified binding function doesn't exist`, () => {
      (riba.formatters.awesome as any) = {};
      valueInput = document.createElement("input");
      valueInput.setAttribute("type", "text");
      valueInput.setAttribute("data-value", "obj.name | awesome");

      view = riba.bind(valueInput, { obj: { name: "nothing" } });
      binding = view.bindings[0] as TextBinder;
      model = binding.model;

      vi.spyOn(binding, "routine");

      valueInput.value = "charles";
      binding.publish();
      expect(adapter.set).toHaveBeenCalledWith(model, "name", "charles");

      binding.set("fred");
      expect(binding.routine).toHaveBeenCalledWith(valueInput, "fred");
    });

    it(`should apply read binders left to right, and write binders right to left`, () => {
      (riba.formatters.totally as Formatter) = {
        name: "totally",
        publish: (value) => value + " totally",
        read: (value) => value + " totally",
      };

      (riba.formatters.awesome as Formatter) = {
        name: "awesome",
        publish: (value) => value + " is awesome",
        read: (value) => value + " is awesome",
      };

      valueInput = document.createElement("input");
      valueInput.setAttribute("type", "text");
      valueInput.setAttribute("data-value", "obj.name | awesome | totally");

      view = riba.bind(valueInput, { obj: { name: "nothing" } });
      binding = view.bindings[0] as TextBinder;
      model = binding.model;

      vi.spyOn(binding, "routine");

      binding.set("fred");
      expect(binding.routine).toHaveBeenCalledWith(
        valueInput,
        "fred is awesome totally"
      );

      valueInput.value = "fred";
      binding.publish();
      expect(adapter.set).toHaveBeenCalledWith(
        model,
        "name",
        "fred totally is awesome"
      );
    });

    it(`binders in a chain should be skipped if they're not there`, () => {
      (riba.formatters.totally as Formatter) = {
        name: "totally",
        publish: (value) => value + " totally",
        read: (value) => value + " totally",
      };

      (riba.formatters.radical as Formatter) = {
        name: "radical",
        publish: (value) => value + " is radical",
      };

      (riba.formatters.awesome as Formatter) = {
        name: "awesome",
        read: (value) => value + " is awesome",
      };

      valueInput = document.createElement("input");
      valueInput.setAttribute("type", "text");
      valueInput.setAttribute(
        "data-value",
        "obj.name | awesome | radical | totally"
      );

      view = riba.bind(valueInput, { obj: { name: "nothing" } });
      binding = view.bindings[0] as ValueBinder;
      vi.spyOn(binding, "routine");

      expect(binding.name).toEqual("value");
      model = binding.model;

      // TODO fixme
      // expect(binding.routine).toHaveBeenCalledWith(
      //   valueInput,
      //   "nothing is awesome totally"
      // );

      binding.set("fred");
      expect(binding.routine).toHaveBeenCalledWith(
        valueInput,
        "fred is awesome totally"
      );

      valueInput.value = "fred";
      binding.publish();
      expect(adapter.set).toHaveBeenCalledWith(
        model,
        "name",
        "fred totally is radical"
      );
    });
  });

  describe("formattedValue()", () => {
    it("applies the current formatters on the supplied value", () => {
      if (!view.options.formatters) {
        throw new Error("formatters are undefined!");
      }
      view.options.formatters.awesome = {
        name: "awesome",
        read(value: string) {
          return "awesome " + value;
        },
      };
      if (!binding.formatters) {
        throw new Error("Formatters not set!");
      }
      binding.formatters.push("awesome");
      expect(binding.formattedValue("hat")).toEqual("awesome hat");
    });

    describe("with a multi-argument formatter string", () => {
      beforeEach(() => {
        if (!view.options.formatters) {
          throw new Error("formatters are undefined!");
        }
        view.options.formatters.awesome = {
          name: "awesome",
          read(value: string, prefix: string) {
            return prefix + " awesome " + value;
          },
        };

        if (!binding.formatters) {
          throw new Error("Formatters not set!");
        }
        binding.formatters.push(`awesome 'super'`);
      });

      it("applies the formatter with arguments", () => {
        expect(binding.formattedValue("jacket")).toEqual(
          "super awesome jacket"
        );
      });
    });

    describe("with a formatter string with pipes in argument", () => {
      beforeEach(() => {
        if (!view.options.formatters) {
          throw new Error("formatters are undefined!");
        }
        view.options.formatters.totally = {
          name: "totally",
          read(value: string, prefix: string) {
            return prefix + " totally " + value;
          },
        };

        if (!binding.formatters) {
          throw new Error("Formatters not set!");
        }
        binding.formatters.push(`totally 'arg | with || pipes'`);
      });

      it("applies the formatter with arguments with pipes", () => {
        expect(binding.formattedValue("jacket")).toEqual(
          "arg | with || pipes totally jacket"
        );
      });
    });
  });

  describe("getValue()", () => {
    it("should use binder.getValue() if present", () => {
      binding.getValue = () => {
        return "foo";
      };

      expect(binding._getValue(el)).toEqual("foo");
    });

    it("binder.getValue() should have access to passed element", () => {
      binding.getValue = (_el: HTMLElement) => {
        return _el.dataset.foo;
      };

      el.dataset.foo = "bar";
      expect(binding._getValue(el)).toEqual("bar");
    });

    it("binder.getValue() should have access to binding", () => {
      binding.getValue = function () {
        return (this as any).foo;
      };

      (binding as any).foo = "bar";
      expect(binding._getValue(el)).toEqual("bar");
    });
  });
});

describe("Functional", () => {
  const riba = new Riba();
  riba.module.adapter.register(dotAdapter);
  riba.module.binder.register(TextBinder);
  riba.module.binder.register(HtmlBinder);
  riba.module.binder.register(ValueBinder);
  riba.module.binder.register(EachStarBinder);
  riba.module.binder.register(AddClassBinder);

  let data: Data;
  let bindData: { data: Data };
  let el: HTMLUnknownElement;
  let input: HTMLInputElement;
  let originalPrefix: string[];
  let adapter: Adapter;

  beforeEach(() => {
    originalPrefix = riba.prefix;
    riba.prefix = ["data"];
    adapter = {
      name: ":",
      observe: (obj, keypath, callback) => {
        obj.on(keypath, callback);
      },
      unobserve: (obj, keypath, callback) => {
        obj.off(keypath, callback);
      },
      get: (obj, keypath) => {
        return obj.get(keypath);
      },
      set: (obj, keypath, value) => {
        const attributes: { [keypath: string]: any } = {};
        attributes[keypath] = value;
        obj.set(attributes);
      },
    };
    riba.module.adapter.register(adapter);
    riba.configure({ preloadData: true });

    data = new Data({
      foo: "bar",
      items: [{ name: "a" }, { name: "b" }],
    });

    bindData = { data };

    el = document.createElement("div");
    el.setAttribute("rv-value", "");
    input = document.createElement("input");
    input.setAttribute("type", "text");
  });

  afterEach(() => {
    riba.prefix = originalPrefix;
  });

  describe("Binds", () => {
    describe("Text", () => {
      it("should set the text content of the element", () => {
        el.setAttribute("data-text", "data:foo");
        riba.bind(el, bindData);
        expect(el.textContent).toEqual(data.get("foo"));
      });

      it("should correctly handle HTML in the content", () => {
        el.setAttribute("data-text", "data:foo");
        const value = "<b>Fail</b>";
        data.set({ foo: value });
        riba.bind(el, bindData);
        expect(el.textContent).toEqual(value);
      });
    });

    describe("HTML", () => {
      it("should set the html content of the element", () => {
        el.setAttribute("data-html", "data:foo");
        riba.bind(el, bindData);
        expect(el.textContent).toEqual(data.get("foo"));
      });

      it("should correctly handle HTML in the content", () => {
        el.setAttribute("data-html", "data:foo");
        const value = "<b>Fail</b>";
        data.set({ foo: value });
        riba.bind(el, bindData);
        expect(el.innerHTML).toEqual(value);
      });
    });

    describe("Value", () => {
      it("should set the value of the element", () => {
        input.setAttribute("data-value", "data:foo");
        riba.bind(input, bindData);
        expect(input.value).toEqual(data.get("foo"));
      });
    });

    describe("Multiple", () => {
      it("should bind a list of multiple elements", () => {
        el.setAttribute("data-html", "data:foo");
        input.setAttribute("data-value", "data:foo");
        riba.bind([el, input], bindData);
        expect(el.textContent).toEqual(data.get("foo"));
        expect(input.value).toEqual(data.get("foo"));
      });
    });

    describe("Priority", () => {
      let mockA: any; // TODO: vi.Mock<any, any>;
      let mockB: any; // TODO: vi.Mock<any, any>;
      let mockC: any; // TODO: vi.Mock<any, any>;
      let mockD: any; // TODO: vi.Mock<any, any>;
      let mockE: any; // TODO: vi.Mock<any, any>;
      let mockF: any; // TODO: vi.Mock<any, any>;
      let mockG: any; // TODO: vi.Mock<any, any>;
      beforeEach(() => {
        mockA = vi.fn();
        mockB = vi.fn();
        mockC = vi.fn();
        mockD = vi.fn();
        mockE = vi.fn();
        mockF = vi.fn();
        mockG = vi.fn();

        class ABinder extends Binder<any, any> {
          static key = "a";
          bind = mockA;
          routine() {
            /**/
          }
        }

        class BBinder extends Binder<any, any> {
          static key = "b";
          bind = mockB;
          routine() {
            /**/
          }
        }

        class CBinder extends Binder<any, any> {
          static key = "c";
          priority = 10;
          bind = mockC;
          routine() {
            /**/
          }
        }

        class DBinder extends Binder<any, any> {
          static key = "d";
          priority = 30;
          bind = mockD;
          routine() {
            /**/
          }
        }

        class EBinder extends Binder<any, any> {
          static key = "e";
          priority = 5;
          bind = mockE;
          routine() {
            /**/
          }
        }

        class FBinder extends Binder<any, any> {
          static key = "f";
          priority = 2;
          bind = mockF;
          routine() {
            /**/
          }
        }

        class GBinder extends Binder<any, any> {
          static key = "g";
          priority = 1;
          bind = mockG;
          routine() {
            /**/
          }
        }

        riba.module.binder.register(ABinder);
        riba.module.binder.register(BBinder);
        riba.module.binder.register(CBinder);
        riba.module.binder.register(DBinder);
        riba.module.binder.register(EBinder);
        riba.module.binder.register(FBinder);
        riba.module.binder.register(GBinder);

        el.setAttribute("data-a", "data:foo");
        el.setAttribute("data-b", "data:foo");
        el.setAttribute("data-c", "data:foo");
        el.setAttribute("data-d", "data:foo");
        el.setAttribute("data-e", "data:foo");
        el.setAttribute("data-f", "data:foo");
        el.setAttribute("data-g", "data:foo");
      });

      describe("a:10, b:30", () => {
        beforeEach(() => {
          const view = riba.bind(el, bindData);
          const c = view.bindings[1];
          const d = view.bindings[0];
          expect(c.name).toEqual("c");
          expect(c.priority).toEqual(10);
          expect(d.name).toEqual("d");
          expect(d.priority).toEqual(30);
        });

        it("should bind d before c", () => {
          expect(mockD).toHaveBeenCalledBefore(mockC);
        });
      });

      describe("a:5, b:2", () => {
        beforeEach(() => {
          const view = riba.bind(el, bindData);
          const e = view.bindings[2];
          const f = view.bindings[3];
          expect(e.name).toEqual("e");
          expect(e.priority).toEqual(5);
          expect(f.name).toEqual("f");
          expect(f.priority).toEqual(2);
        });

        it("should bind e before f", () => {
          expect(mockE).toHaveBeenCalledBefore(mockF);
        });
      });

      describe("a:undefined, g:1", () => {
        beforeEach(() => {
          const view = riba.bind(el, bindData);
          const g = view.bindings[4];
          expect(g.name).toEqual("g");
          expect(g.priority).toEqual(1);
          riba.bind(el, bindData);
        });

        it("should bind g before a", () => {
          expect(mockG).toHaveBeenCalledBefore(mockA);
        });
      });
    });

    describe("Iteration", () => {
      let listItem: HTMLLIElement;
      let list: HTMLUListElement;
      beforeEach(() => {
        list = document.createElement("ul");
        el.appendChild(list);
        listItem = document.createElement("li");
        listItem.setAttribute("data-each-item", "data:items");
        list.appendChild(listItem);
      });

      it("should loop over a collection and create new instances of that element + children", () => {
        expect(el.getElementsByTagName("li").length).toEqual(1);
        riba.bind(el, bindData);
        expect(el.getElementsByTagName("li").length).toEqual(2);
      });

      it("should not fail if the collection being bound to is null", () => {
        data.set({ items: null });
        riba.bind(el, bindData);
        expect(el.getElementsByTagName("li").length).toEqual(0);
      });

      it("should re-loop over the collection and create new instances when the array changes", () => {
        riba.bind(el, bindData);
        expect(el.getElementsByTagName("li").length).toEqual(2);

        const newItems = [{ name: "a" }, { name: "b" }, { name: "c" }];
        data.set({ items: newItems });
        expect(el.getElementsByTagName("li").length).toEqual(3);
      });

      it("should allow binding to the iterated item as well as any parent contexts", () => {
        const span1 = document.createElement("span");
        span1.setAttribute("data-text", "item.name");
        const span2 = document.createElement("span");
        span2.setAttribute("data-text", "data:foo");
        listItem.appendChild(span1);
        listItem.appendChild(span2);

        riba.bind(el, bindData);
        expect(el.getElementsByTagName("span")[0].textContent).toEqual("a");
        expect(el.getElementsByTagName("span")[1].textContent).toEqual("bar");
      });

      it("should allow binding to the iterated element directly", () => {
        listItem.setAttribute("data-text", "item.name");
        listItem.setAttribute("data-add-class", "data:foo");
        riba.bind(el, bindData);
        expect(el.getElementsByTagName("li")[0].textContent).toEqual("a");
        expect(el.getElementsByTagName("li")[0].className).toEqual("bar");
      });

      it("should insert items between any surrounding elements", () => {
        const firstItem = document.createElement("li");
        const lastItem = document.createElement("li");
        firstItem.textContent = "first";
        lastItem.textContent = "last";
        list.appendChild(lastItem);
        list.insertBefore(firstItem, listItem);
        listItem.setAttribute("data-text", "item.name");

        riba.bind(el, bindData);

        expect(el.getElementsByTagName("li")[0].textContent).toEqual("first");
        expect(el.getElementsByTagName("li")[1].textContent).toEqual("a");
        expect(el.getElementsByTagName("li")[2].textContent).toEqual("b");
        expect(el.getElementsByTagName("li")[3].textContent).toEqual("last");
      });

      // TODO fixme
      it.skip("should allow binding to the iterated element index", () => {
        listItem.setAttribute("data-index", "%item%");
        riba.bind(el, bindData);
        expect(el.getElementsByTagName("li")[0].getAttribute("index")).toEqual(
          "0"
        );
        expect(el.getElementsByTagName("li")[1].getAttribute("index")).toEqual(
          "1"
        );
      });

      // TODO fixme
      it.skip("should allow the developer to configure the index attribute available in the iteration", () => {
        listItem.setAttribute("data-index", "itemIndex");
        listItem.setAttribute("index-property", "itemIndex");
        riba.bind(el, bindData);
        expect(el.outerHTML).toEqual(""); // debug
        expect(el.getElementsByTagName("li")[0].getAttribute("index")).toEqual(
          "0"
        );
        expect(el.getElementsByTagName("li")[1].getAttribute("index")).toEqual(
          "1"
        );
      });
    });
  });

  describe("Updates", () => {
    it("should change the value", () => {
      el.setAttribute("data-text", "data:foo");
      riba.bind(el, bindData);
      data.set({ foo: "some new value" });
      expect(el.textContent).toEqual(data.get("foo"));
    });
  });

  describe("Input", () => {
    it("should update the model value", () => {
      input.setAttribute("data-value", "data:foo");
      riba.bind(input, bindData);
      input.value = "some new value";
      const event = document.createEvent("HTMLEvents");
      event.initEvent("input", true, true);
      input.dispatchEvent(event);
      expect(data.get("foo")).toEqual("some new value");
    });

    it("should allow to change the event listened", () => {
      let event;
      input.setAttribute("data-value", "data:foo");
      input.setAttribute("event-name", "blur");
      riba.bind(input, bindData);
      input.value = "some new value";
      event = document.createEvent("HTMLEvents");
      event.initEvent("input", true, true);
      input.dispatchEvent(event);
      expect(data.get("foo")).toEqual("bar");

      event = document.createEvent("HTMLEvents");
      event.initEvent("blur", true, true);
      input.dispatchEvent(event);
      expect(data.get("foo")).toEqual("some new value");
    });
  });
});
