import type { Applet, AppletEvent, AppletScope } from "@web-applets/sdk";

import * as Uint8 from "uint8arrays";
import { applets } from "@web-applets/sdk";
import { type ElementConfigurator, h } from "spellcaster/hyperscript.js";
import { effect, isSignal, type Signal, signal } from "spellcaster/spellcaster.js";
import { xxh32 } from "xxh32";
import QS from "query-string";

import type { ResolvedUri, Track } from "@applets/core/types";

////////////////////////////////////////////
// 🪟 Applet connecting
////////////////////////////////////////////
export async function applet<D>(
  src: string,
  opts: {
    addSlashSuffix?: boolean;
    container?: HTMLElement | Element;
    frameId?: string;
    groupId?: string;
    setHeight?: boolean;
  } = {},
): Promise<Applet<D>> {
  src = `${src}${
    src.endsWith("/")
      ? ""
      : opts.addSlashSuffix === undefined || opts.addSlashSuffix === true
        ? "/"
        : ""
  }`;

  let query: undefined | Record<string, string>;

  if (opts.groupId) {
    query = { groupId: opts.groupId };
  }

  if (query) {
    src = QS.stringifyUrl({ url: src, query });
  }

  const context = self.top || self.parent;
  const existingFrame: HTMLIFrameElement | null = context.document.querySelector(`[src="${src}"]`);

  let frame;

  if (existingFrame) {
    frame = existingFrame;
  } else {
    frame = document.createElement("iframe");
    frame.src = src;
    if (opts.frameId) frame.id = opts.frameId;

    if (opts.container) {
      opts.container.appendChild(frame);
    } else {
      context.document.body.appendChild(frame);
    }
  }

  if (frame.contentWindow === null) {
    throw new Error("iframe does not have a contentWindow");
  }

  const applet = await applets.connect<D>(frame.contentWindow, { context }).catch((err) => {
    console.error("Error connecting to " + src, err);
    throw err;
  });

  if (opts.setHeight) {
    applet.onresize = () => {
      frame.height = `${applet.height}px`;
      frame.classList.add("has-loaded");
    };
  } else {
    if (frame.contentDocument?.readyState === "complete") {
      frame.classList.add("has-loaded");
    }

    frame.addEventListener("load", () => {
      frame.classList.add("has-loaded");
    });
  }

  return applet;
}

////////////////////////////////////////////
// 🪟 Applet registration
////////////////////////////////////////////
export type BroadcastedApplet<T> = {
  groupId: string | undefined;
  scope: AppletScope<T>;

  settled(): Promise<void>;

  get instanceId(): string;
  set data(data: T);

  codec: {
    decode(data: any): T;
    encode(data: T): any;
  };

  isMainInstance(): boolean;
  setActionHandler<H extends Function>(actionId: string, actionHandler: H): void;
};

export function register<DataType = any>(): BroadcastedApplet<DataType> {
  const url = new URL(location.href);
  const scope = applets.register<DataType>();

  const groupId = url.searchParams.get("groupId") || "main";
  const channelId = `${location.host}${location.pathname}/${groupId}`;
  const instanceId = crypto.randomUUID();

  let isMainInstance = true;

  // One instance to rule them all
  //
  // Ping other instances to see if there are any.
  // As long as there aren't any, it is considered the main instance.
  //
  // Actions are performed on the main instance,
  // and data is replicated from main to the other instances.
  const channel = new BroadcastChannel(channelId);

  channel.addEventListener("message", async (event) => {
    switch (event.data?.type) {
      case "PING": {
        channel.postMessage({
          type: "PONG",
          instanceId: event.data.instanceId,
        });

        if (isMainInstance) {
          channel.postMessage({
            type: "data",
            data: context.codec.encode(scope.data),
          });
        }
        break;
      }

      case "PONG": {
        if (event.data.instanceId === instanceId) {
          isMainInstance = false;
        }
        break;
      }

      case "action": {
        if (isMainInstance) {
          const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments);
          channel.postMessage({
            type: "actioncomplete",
            actionInstanceId: event.data.actionInstanceId,
            result,
          });
        }
        break;
      }

      case "data": {
        scope.data = context.codec.decode(event.data.data);
        break;
      }
    }
  });

  // Promise that fullfills whenever it figures out its the main instance or not.
  const promise = new Promise<void>((resolve) => {
    const timeoutId = setTimeout(() => {
      channel.removeEventListener("message", handler);
      resolve(undefined);
    }, 1000);

    const handler = (event: MessageEvent) => {
      if (event.data === "pong" || event.data === "ping") {
        clearTimeout(timeoutId);
        channel.removeEventListener("message", handler);
        resolve(undefined);
      }
    };

    channel.addEventListener("message", handler);
  });

  // Send out ping
  channel.postMessage({
    type: "PING",
    instanceId,
  });

  // If the data on the main instance changes,
  // pass it on to other instances.
  scope.addEventListener("data", async (event: AppletEvent) => {
    await promise;

    if (isMainInstance) {
      channel.postMessage({
        type: "data",
        data: context.codec.encode(event.data),
      });
    }
  });

  // Context
  const context: BroadcastedApplet<DataType> = {
    groupId,
    scope,

    settled() {
      return promise;
    },

    get instanceId() {
      return instanceId;
    },

    get data() {
      return scope.data;
    },

    set data(data: DataType) {
      scope.data = data;
    },

    codec: {
      decode: (data: any) => data as DataType,
      encode: (data: DataType) => data as any,
    },

    isMainInstance() {
      return isMainInstance;
    },

    setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => {
      const handler = (...args: any) => {
        if (isMainInstance) {
          return actionHandler(...args);
        }

        const actionMessage = {
          actionInstanceId: crypto.randomUUID(),
          actionId,
          type: "action",
          arguments: args,
        };

        return new Promise((resolve) => {
          const actionCallback = (event: MessageEvent) => {
            if (
              event.data?.type === "actioncomplete" &&
              event.data?.actionInstanceId === actionMessage.actionInstanceId
            ) {
              channel.removeEventListener("message", actionCallback);
              resolve(event.data.result);
            }
          };

          channel.addEventListener("message", actionCallback);
          channel.postMessage(actionMessage);
        });
      };

      scope.setActionHandler(actionId, handler);
    },
  };

  return context;
}

////////////////////////////////////////////
// 🔮 Reactive state management
////////////////////////////////////////////
export function reactive<D, T>(
  applet: Applet<D>,
  dataFn: (data: D) => T,
  effectFn: (t: T, setter: (t: T) => void) => void,
) {
  const [getter, setter] = signal(dataFn(applet.data));

  effect(() => {
    effectFn(getter(), setter);
    return undefined;
  });

  applet.addEventListener("data", (event: AppletEvent) => {
    setter(dataFn(event.data));
  });
}

////////////////////////////////////////////
// ⚡️ COMMON ACTION CALLS
////////////////////////////////////////////

export async function inputUrl(input: Applet, uri: string, method = "GET") {
  return await input.sendAction<ResolvedUri>(
    "resolve",
    {
      method,
      uri,
    },
    {
      timeoutDuration: 60000 * 5,
    },
  );
}

////////////////////////////////////////////
// 🛠️
////////////////////////////////////////////
export function addScope<O extends object>(astroScope: string, object: O): O {
  return {
    ...object,
    attrs: {
      ...((object as any).attrs || {}),
      [`data-astro-cid-${astroScope}`]: "",
    },
  };
}

export function appletScopePort() {
  let port: MessagePort | undefined;

  function connection(event: AppletEvent) {
    if (event.data?.type === "appletconnect") {
      window.removeEventListener("message", connection);
      port = (event as any).ports[0];
    }
  }

  window.addEventListener("message", connection);

  return () => port;
}

export function cleanUndefinedValuesForTracks(tracks: Track[]): Track[] {
  return tracks.map((track) => {
    const t = { ...track };

    if (t.tags) {
      if ("album" in t.tags && t.tags.album === undefined) delete t.tags.album;
      if ("artist" in t.tags && t.tags.artist === undefined) delete t.tags.artist;
      if ("genre" in t.tags && t.tags.genre === undefined) delete t.tags.genre;
      if ("year" in t.tags && t.tags.year === undefined) delete t.tags.year;

      if ("of" in t.tags.disc && t.tags.disc.of === undefined) delete t.tags.disc.of;
      if ("of" in t.tags.track && t.tags.track.of === undefined) delete t.tags.track.of;
    }

    return t;
  });
}

export function comparable(value: unknown) {
  return xxh32(JSON.stringify(value));
}

export function hs(
  tag: string,
  astroScope: string,
  props?: Record<string, unknown> | Signal<Record<string, unknown>>,
  configure?: ElementConfigurator,
) {
  const propsWithScope =
    props && isSignal(props)
      ? () => addScope(astroScope, props())
      : addScope(astroScope, props || {});

  return h(tag, propsWithScope, configure);
}

export function isPrimitive(test: unknown) {
  return test !== Object(test);
}

export function jsonDecode<T>(a: any): T {
  return JSON.parse(new TextDecoder().decode(a));
}

export function jsonEncode<T>(a: T): Uint8Array {
  return new TextEncoder().encode(JSON.stringify(a));
}

export async function trackArtworkCacheId(track: Track): Promise<string> {
  return await crypto.subtle
    .digest("SHA-256", new TextEncoder().encode(track.uri))
    .then((a) => Uint8.toString(new Uint8Array(a), "base64url"));
}

export function wait<A>(applet: Applet<A>, dataFn: (a: A | undefined) => boolean): Promise<void> {
  return new Promise((resolve) => {
    if (dataFn(applet.data) === true) {
      resolve();
      return;
    }

    const callback = (event: AppletEvent) => {
      if (dataFn(event.data) === true) {
        applet.removeEventListener("data", callback);
        resolve();
      }
    };

    applet.addEventListener("data", callback);
  });
}
