import { isArrayBufferView } from "util/types";

export type ObservableMap<K, V> = Map<K, V> & {
  onAnyUpdate: (callback: () => void) => () => void;
  onUpdate: (key: string, callback: () => void) => () => void;
};

//FIXME: allow different type for different properties
export function memoryMap<T>(
  backend: Map<string, T> = new Map<string, T>(),
): ObservableMap<string, T> {
  const obs = new EventTarget();
  const theMemoryMap: ObservableMap<string, T> = {
    onAnyUpdate: (handler) => {
      obs.addEventListener(`update`, handler);
      obs.addEventListener(`clear`, handler);
      return () => {
        obs.removeEventListener(`update`, handler);
        obs.removeEventListener(`clear`, handler);
      };
    },
    onUpdate: (key, handler) => {
      obs.addEventListener(`update-${key}`, handler);
      obs.addEventListener(`clear`, handler);
      return () => {
        obs.removeEventListener(`update-${key}`, handler);
        obs.removeEventListener(`clear`, handler);
      };
    },
    delete: (key: string) => {
      const result = backend.delete(key);
      //@ts-ignore
      theMemoryMap.size = backend.length;
      obs.dispatchEvent(new Event(`update-${key}`));
      obs.dispatchEvent(new Event(`update`));
      return result;
    },
    set: (key: string, value: T) => {
      backend.set(key, value);
      //@ts-ignore
      theMemoryMap.size = backend.length;
      obs.dispatchEvent(new Event(`update-${key}`));
      obs.dispatchEvent(new Event(`update`));
      return theMemoryMap;
    },
    clear: () => {
      backend.clear();
      obs.dispatchEvent(new Event(`clear`));
    },
    entries: backend.entries.bind(backend),
    forEach: backend.forEach.bind(backend),
    get: backend.get.bind(backend),
    has: backend.has.bind(backend),
    keys: backend.keys.bind(backend),
    size: backend.size,
    values: backend.values.bind(backend),
    [Symbol.iterator]: backend[Symbol.iterator],
    [Symbol.toStringTag]: "theMemoryMap",
  };
  return theMemoryMap;
}

//FIXME: change this implementation to match the
// browser storage. instead of creating a sync implementation
// of observable map it should reuse the memoryMap and
// sync the state with local storage
export function localStorageMap(): ObservableMap<string, string> {
  const obs = new EventTarget();
  const theLocalStorageMap: ObservableMap<string, string> = {
    onAnyUpdate: (handler) => {
      obs.addEventListener(`update`, handler);
      obs.addEventListener(`clear`, handler);
      window.addEventListener("storage", handler);
      return () => {
        window.removeEventListener("storage", handler);
        obs.removeEventListener(`update`, handler);
        obs.removeEventListener(`clear`, handler);
      };
    },
    onUpdate: (key, handler) => {
      obs.addEventListener(`update-${key}`, handler);
      obs.addEventListener(`clear`, handler);
      function handleStorageEvent(ev: StorageEvent) {
        if (ev.key === null || ev.key === key) {
          handler();
        }
      }
      window.addEventListener("storage", handleStorageEvent);
      return () => {
        window.removeEventListener("storage", handleStorageEvent);
        obs.removeEventListener(`update-${key}`, handler);
        obs.removeEventListener(`clear`, handler);
      };
    },
    delete: (key: string) => {
      const exists = localStorage.getItem(key) !== null;
      localStorage.removeItem(key);
      //@ts-ignore
      theLocalStorageMap.size = localStorage.length;
      obs.dispatchEvent(new Event(`update-${key}`));
      obs.dispatchEvent(new Event(`update`));
      return exists;
    },
    set: (key: string, v: string) => {
      localStorage.setItem(key, v);
      //@ts-ignore
      theLocalStorageMap.size = localStorage.length;
      obs.dispatchEvent(new Event(`update-${key}`));
      obs.dispatchEvent(new Event(`update`));
      return theLocalStorageMap;
    },
    clear: () => {
      localStorage.clear();
      obs.dispatchEvent(new Event(`clear`));
    },
    entries: (): IterableIterator<[string, string]> => {
      let index = 0;
      const total = localStorage.length;
      return {
        next() {
          if (index === total) return { done: true, value: undefined };
          const key = localStorage.key(index);
          if (key === null) {
            //we are going from 0 until last, this should not happen
            throw Error("key cant be null");
          }
          const item = localStorage.getItem(key);
          if (item === null) {
            //the key exist, this should not happen
            throw Error("value cant be null");
          }
          index = index + 1;
          return { done: false, value: [key, item] };
        },
        [Symbol.iterator]() {
          return this;
        },
      };
    },
    forEach: (cb) => {
      for (let index = 0; index < localStorage.length; index++) {
        const key = localStorage.key(index);
        if (key === null) {
          //we are going from 0 until last, this should not happen
          throw Error("key cant be null");
        }
        const item = localStorage.getItem(key);
        if (item === null) {
          //the key exist, this should not happen
          throw Error("value cant be null");
        }
        cb(key, item, theLocalStorageMap);
      }
    },
    get: (key: string) => {
      const item = localStorage.getItem(key);
      if (item === null) return undefined;
      return item;
    },
    has: (key: string) => {
      return localStorage.getItem(key) === null;
    },
    keys: () => {
      let index = 0;
      const total = localStorage.length;
      return {
        next() {
          if (index === total) return { done: true, value: undefined };
          const key = localStorage.key(index);
          if (key === null) {
            //we are going from 0 until last, this should not happen
            throw Error("key cant be null");
          }
          index = index + 1;
          return { done: false, value: key };
        },
        [Symbol.iterator]() {
          return this;
        },
      };
    },
    size: localStorage.length,
    values: () => {
      let index = 0;
      const total = localStorage.length;
      return {
        next() {
          if (index === total) return { done: true, value: undefined };
          const key = localStorage.key(index);
          if (key === null) {
            //we are going from 0 until last, this should not happen
            throw Error("key cant be null");
          }
          const item = localStorage.getItem(key);
          if (item === null) {
            //the key exist, this should not happen
            throw Error("value cant be null");
          }
          index = index + 1;
          return { done: false, value: item };
        },
        [Symbol.iterator]() {
          return this;
        },
      };
    },
    [Symbol.iterator]: function (): IterableIterator<[string, string]> {
      return theLocalStorageMap.entries();
    },
    [Symbol.toStringTag]: "theLocalStorageMap",
  };
  return theLocalStorageMap;
}

const isFirefox =
  typeof (window as any) !== "undefined" &&
  typeof (window as any)["InstallTrigger"] !== "undefined";

async function getAllContent() {
  //Firefox and Chrome has different storage api
  if (isFirefox) {
    // @ts-ignore
    return browser.storage.local.get();
  } else {
    return chrome.storage.local.get();
  }
}

async function updateContent(obj: Record<string, any>) {
  if (isFirefox) {
    // @ts-ignore
    return browser.storage.local.set(obj);
  } else {
    return chrome.storage.local.set(obj);
  }
}
type Changes = { [key: string]: { oldValue?: any; newValue?: any } };
function onBrowserStorageUpdate(cb: (changes: Changes) => void): void {
  if (isFirefox) {
    // @ts-ignore
    browser.storage.local.onChanged.addListener(cb);
  } else {
    chrome.storage.local.onChanged.addListener(cb);
  }
}

export function browserStorageMap(
  backend: ObservableMap<string, string>,
): ObservableMap<string, string> {
  getAllContent().then(content => {
    Object.entries(content ?? {}).forEach(([k, v]) => {
      backend.set(k, v as string);
    });
  })

  backend.onAnyUpdate(async () => {
    const result: Record<string, string> = {};
    for (const [key, value] of backend.entries()) {
      result[key] = value;
    }
    await updateContent(result);
  });

  onBrowserStorageUpdate((changes) => {
    //another chrome instance made the change
    const changedItems = Object.keys(changes);
    if (changedItems.length === 0) {
      backend.clear();
    } else {
      for (const key of changedItems) {
        if (!changes[key].newValue) {
          backend.delete(key);
        } else {
          if (changes[key].newValue !== changes[key].oldValue) {
            backend.set(key, changes[key].newValue);
          }
        }
      }
    }
  });

  return backend;
}
