import { useActorRef, useSelector } from "@xstate/react";
import { useMemo, type ReactNode } from "react";
import { Actor, fromCallback, fromPromise } from "xstate";
import { useAppConfig } from "../AppConfig/AppConfig.tsx";
import { Failure, Success } from "../async-op.ts";
import type { IndieTabletopClient } from "../client.ts";
import { createStrictContext } from "../createStrictContext.ts";
import type { ModernIDB } from "../ModernIDB/ModernIDB.ts";
import type { ModernIDBIndexes, ModernIDBSchema } from "../ModernIDB/types.ts";
import type { CurrentUser } from "../types.ts";
import {
  machine,
  type AppMachine,
  type PullChangesInput,
  type PushChangesInput,
} from "./store.ts";
import type { MachineEvent, PullResult, PushResult } from "./types.ts";
import { toSyncedItems, type UserGameDataShape } from "./utils.ts";

export type AppActions = ReturnType<typeof useCreateActions>;

type AppActor = Actor<AppMachine>;

export const [AppMachineContext, useAppMachineContext] = createStrictContext<{
  actorRef: AppActor;
  actions: AppActions;
}>("App Machine");

export function useCurrentUser() {
  const { actorRef } = useAppMachineContext();
  return useSelector(actorRef, (s) => s.context.currentUser);
}

export function useSessionInfo() {
  const { actorRef } = useAppMachineContext();
  return useSelector(actorRef, (s) => s.context.sessionInfo);
}

/**
 * Checks if the app is in an authenticated state.
 *
 * This means that the user that might have been provided in local storage
 * has been checked with the server and they a) have a valid session and
 * b) are matching the current user.
 */
export function useIsAuthenticated() {
  const { actorRef } = useAppMachineContext();
  return useSelector(actorRef, (s) => s.matches("authenticated"));
}

export function useSyncLog() {
  const { actorRef } = useAppMachineContext();
  return useSelector(actorRef, (s) => s.context.syncLog);
}

export function useLastSuccessfulSyncTs() {
  const { actorRef } = useAppMachineContext();
  return useSelector(actorRef, (s) => s.context.lastSuccessfulSyncTs);
}

export type SyncState = ReturnType<typeof useSyncState>;

/**
 * Returns the app's current {@link SyncState}.
 *
 * Note that this is not a property within the app's machine context, it is a
 * derived value based on several factors.
 *
 */
export function useSyncState() {
  const { actorRef } = useAppMachineContext();
  return useSelector(actorRef, (s) => {
    switch (true) {
      case s.matches({ authenticated: { sync: "syncing" } }): {
        return "SYNCING";
      }

      case s.matches({ authenticated: { sync: "waiting" } }): {
        return "WAITING";
      }

      case s.matches({ authenticated: { sync: "idle" } }): {
        const lastSync = s.context.syncLog[0];

        if (
          lastSync?.pull?.type === "FAILURE" ||
          lastSync?.push?.type === "FAILURE"
        ) {
          return "ERROR";
        }

        return "IDLE";
      }

      case s.matches("unauthenticated") && !!s.context.currentUser: {
        return "AUTHENTICATING";
      }

      default: {
        return "INACTIVE";
      }
    }
  });
}

export function useAppActions() {
  return useAppMachineContext().actions;
}

export type DatabaseAppMachineMethods<GameDataPayload> = {
  upsertGameData(data: GameDataPayload): Promise<Success<string[]>>;

  getUpdatedGameDataSince(props: {
    sinceTs: number | null;
    exclude: Set<string>;
  }): Promise<Success<GameDataPayload>>;

  clearAll(): Promise<Success<string> | Failure<string>>;
};

export function createMachine<
  Schema extends ModernIDBSchema,
  Indexes extends ModernIDBIndexes<Schema>,
  SnapshotPayload,
  GameDataPayload extends UserGameDataShape,
  RulesetPayload,
>(options: {
  database: ModernIDB<Schema, Indexes> &
    DatabaseAppMachineMethods<GameDataPayload>;

  client: IndieTabletopClient<SnapshotPayload, GameDataPayload, RulesetPayload>;

  /**
   * Which games should the machine pull game data for?
   *
   * If null is provided, no data will be pulled.
   *
   * @remarks Honestly this is a bit hacky and we should have a better way to
   * disable sync entirely for apps that don't need it (e.g. the Creators App).
   */
  pullGameDataFor:
    | (string & keyof GameDataPayload)
    | (string & keyof GameDataPayload)[]
    | null;
}) {
  const { client, database } = options;

  // Concrete implementations of actors that our machine requires.

  const auth = fromCallback<MachineEvent>(({ sendBack }) => {
    const controller = new AbortController();

    client.addEventListener(
      "currentUser",
      ({ detail }) => void sendBack({ type: "currentUser", ...detail }),
      controller,
    );

    client.addEventListener(
      "sessionInfo",
      ({ detail }) => void sendBack({ type: "sessionInfo", ...detail }),
      controller,
    );

    client.addEventListener(
      "sessionExpired",
      () => void sendBack({ type: "sessionExpired" }),
      controller,
    );

    return () => {
      controller.abort();
    };
  });

  const sync = fromCallback<MachineEvent>(({ sendBack }) => {
    const controller = new AbortController();

    // As long as sync is active, make sure to `pull` after every window focus.
    window.addEventListener(
      "focus",
      () => void sendBack({ type: "pull" }),
      controller,
    );

    // If there is a `readwrite` transaction in IndexedDB trigger a `push`.
    database.addEventListener(
      "readwrite",
      () => void sendBack({ type: "push" }),
      controller,
    );

    return () => {
      controller.abort();
    };
  });

  const pullChanges = fromPromise<PullResult, PullChangesInput>(
    async ({ input }) => {
      if (!options.pullGameDataFor) {
        return new Success({ pulled: [] });
      }

      const result = await client.pullUserData({
        include: options.pullGameDataFor,
        sinceTs: input.sinceTs,
        expectCurrentUserId: input.currentUser.id,
      });

      const userGameData = result.valueOrThrow();
      await database.upsertGameData(userGameData);

      return new Success({ pulled: toSyncedItems(userGameData) });
    },
  );

  const pushChanges = fromPromise<PushResult, PushChangesInput>(
    async ({ input }) => {
      const { value: changedData } = await database.getUpdatedGameDataSince({
        sinceTs: input.sinceTs,
        exclude: new Set(input.ignoredItems.map((i) => i.id)),
      });

      const changed = toSyncedItems(changedData);

      if (changed.length === 0) {
        return new Success({ pushed: [], pulled: [] });
      }

      const result = await client.pushUserData({
        data: changedData,
        pullSinceTs: input.currentSyncTs,
        currentSyncTs: input.currentSyncTs,
        expectCurrentUserId: input.currentUser.id,
      });

      const userGameData = result.valueOrThrow();
      await database.upsertGameData(userGameData);

      return new Success({
        pushed: changed,
        pulled: toSyncedItems(userGameData),
      });
    },
  );

  return machine.provide({
    actors: { auth, sync, pullChanges, pushChanges },
  });
}

function useCreateActions(app: AppActor) {
  const { database, client } = useAppConfig();

  return useMemo(() => {
    async function clientLogout(params: { serverUser: CurrentUser }) {
      await database.clearAll();
      app.send({ type: "serverUser", ...params });

      // Get new session info
      await client.refreshTokens();
    }

    async function serverLogout() {
      await client.logout();
    }

    async function logout() {
      await client.logout();
      await database.clearAll();
      app.send({ type: "reset" });
    }

    function push() {
      app.send({ type: "push" });
    }

    function pull() {
      app.send({ type: "pull" });
    }

    return {
      pull,
      push,
      logout,
      serverLogout,
      clientLogout,
    };
  }, [app]);
}

export function AppMachineProvider(props: {
  value: typeof machine;
  children: ReactNode;
}) {
  const actorRef = useActorRef(props.value);
  const actions = useCreateActions(actorRef);
  const context = useMemo(() => ({ actorRef, actions }), [actorRef, actions]);

  return (
    <AppMachineContext value={context}>{props.children}</AppMachineContext>
  );
}
