const U = (() => {
  let nextObservableId = 1;
  let reactiveContext = null;

  const createObservable = (initial, { equality = (x, y) => x === y, name } = {}) => {
    const _observableId = nextObservableId++;
    let _value = initial;
    let subscriberNum = 0;
    const subscribers = new Map();

    return {
      _observableId,
      name: name || `obs#${_observableId}`,
      get value() {
        reactiveContext?.add(this);
        return _value;
      },
      set value(newValue) {
        const previous = _value;
        _value = newValue;
        if (!equality?.(previous, _value)) {
          const info = { previous, initial, cause: this };
          subscribers.forEach((fn) => fn(_value, info));
        }
      },
      subscribe(fn) {
        subscriberNum++;
        subscribers.set(subscriberNum, fn);
        return () => subscribers.delete(subscriberNum);
      },
      reset() {
        this.value = initial;
      },
    };
  };

  const trackDependencies = (action) => {
    const prevContext = reactiveContext;
    reactiveContext = new Set();
    const result = action();
    const dependencies = Array.from(reactiveContext.values());
    reactiveContext = prevContext;
    return [result, dependencies];
  };

  const getElement = (elementOrId) =>
    typeof elementOrId === "string" ? document.getElementById(elementOrId) : elementOrId;

  return {
    // U.obs(value);
    // U.obs(() => obs1.value + obs2.value);
    // U.obs([obs1, obs2, obs3]);
    obs(initializer, ...args) {
      if (Array.isArray(initializer) && initializer[0]?._observableId) {
        const getList = () => initializer.map(({ value }) => value);
        const result = createObservable(getList(), ...args);
        initializer.forEach((obs) =>
          obs.subscribe(() => {
            result.value = getList();
          })
        );
        return result;
      }

      if (typeof initializer === "function") {
        const [value, deps] = trackDependencies(initializer);
        const result = createObservable(value, ...args);
        deps.forEach((obs) =>
          obs.subscribe(() => {
            result.value = initializer();
          })
        );
        return result;
      }

      return createObservable(initializer, ...args);
    },

    // U.fsm(states);
    // U.fsm(s1, s2, s3);
    fsm(config, ...rest) {
      let initial, states;
      if (rest.length > 0) {
        // cycle, using [config, ...rest] as states
        initial = config;
        states = Object.fromEntries(
          [config, ...rest].map((state, i, ls) => [state, ls[(i + 1) % ls.length]])
        );
      } else {
        initial = config.initial;
        states = config.states;
      }

      const state = createObservable(initial);

      const transitions = Object.fromEntries(
        Object.entries(states).map(([current, next]) => [
          current,
          typeof next === "string"
            ? () => (state.value = next)
            : (transition) => (state.value = next[transition]),
        ])
      );

      return {
        get current() {
          return state.value;
        },
        get transition() {
          return transitions[state.value];
        },
        subscribe(...args) {
          return state.subscribe(...args);
        },
        reset() {
          state.reset();
        },
      };
    },

    // U.reactive(() => {});
    reactive(action) {
      trackDependencies(action)[1].forEach((obs) => obs.subscribe(() => action()));
    },

    // U.resource(async () => {});
    resource(sourceOrAction, actionOrInitial, ...args) {
      let resultArgs, action, source;
      if (typeof sourceOrAction === "function") {
        resultArgs = [actionOrInitial, ...args];
        action = sourceOrAction;
        source = null;
      } else {
        resultArgs = args;
        action = actionOrInitial;
        source = sourceOrAction;
      }

      const result = createObservable(...resultArgs);
      const loading = createObservable(true);
      const error = createObservable(false);

      const refresh = async () => {
        loading.value = true;
        error.value = false;
        try {
          result.value = await action(source?.value);
        } catch (err) {
          error.value = err;
        }
        loading.value = false;
      };

      refresh();
      source?.subscribe(() => refresh());

      return {
        result,
        loading,
        error,
        refresh,
      };
    },

    // U.element("idOrElem", (binds, element) => {});
    element(idOrRoot, action, keepBindPoints = false) {
      const root = getElement(idOrRoot);
      const binds = Object.fromEntries(
        Array.from(root.querySelectorAll("[bind-to]")).map((element) => {
          const name = element.getAttribute("bind-to");
          if (!keepBindPoints) {
            element.removeAttribute("bind-to");
          }
          return [name, element];
        })
      );
      return action(binds, root);
    },

    // U.template("idOrElem", (binds, api) => {});
    template(idOrTemplate, setup) {
      const fragment = getElement(idOrTemplate).content.cloneNode(true);
      const children = Array.from(fragment.children);
      const api = {
        fragment,
        remove() {
          children.forEach((elem) => elem.remove());
        },
        hide(state = true) {
          children.forEach((elem) => (elem.hidden = state));
        },
      };

      const target = this.element(fragment, (binds) => setup(binds, api));

      if (target) {
        const parent = getElement(target);
        parent.appendChild(fragment);
        return parent;
      } else {
        return fragment;
      }
    },

    // U.form("idOrElem", config);
    form(formOrId, { onSubmit, onChange = {}, onInput = {} } = {}) {
      const formElement = getElement(formOrId);
      formElement.setAttribute("action", "javascript:void(0);");

      formElement.addEventListener("submit", () =>
        onSubmit?.(
          Object.fromEntries(
            Array.from(formElement.elements)
              .filter((elem) => elem.name)
              .map((elem) => [elem.name, elem.value])
          ),
          formElement
        )
      );

      formElement.addEventListener("change", (event) => {
        onChange?.[event.target.name]?.(event.target.value, event.target, event);
      });

      formElement.addEventListener("input", (event) => {
        onInput?.[event.target.name]?.(event.target.value, event.target, event);
      });

      return formElement;
    },

    // U.field("idOrElem", config);
    field(fieldOrId, { allowInvalidInput = false, obs, ...config } = {}) {
      const fieldElement = getElement(fieldOrId);
      const valueKey = fieldElement.type === "checkbox" ? "checked" : "value";
      const fieldObs =
        obs ||
        createObservable(fieldElement[valueKey], {
          name: fieldElement.name,
          ...config,
        });

      fieldElement.addEventListener("input", () => {
        if (
          !allowInvalidInput &&
          fieldElement.willValidate &&
          !fieldElement.validity.valid
        ) {
          fieldElement[valueKey] = fieldObs.value;
        } else {
          fieldObs.value = fieldElement[valueKey];
        }
      });
      this.reactive(() => (fieldElement[valueKey] = fieldObs.value));
      return fieldObs;
    },
  };
})();
