import type { SafeUser } from "bknd";
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
import { DataApi, type DataApiOptions } from "data/api/DataApi";
import { decode } from "hono/jwt";
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi";
import { omitKeys } from "bknd/utils";
import type { BaseModuleApiOptions } from "modules";

export type TApiUser = SafeUser;

export type ApiFetcher = (
   input: RequestInfo | URL,
   init?: RequestInit,
) => Response | Promise<Response>;

declare global {
   interface Window {
      __BKND__: {
         user?: TApiUser;
      };
   }
}

type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;

export type ApiOptions = {
   host?: string;
   headers?: Headers;
   key?: string;
   storage?: {
      getItem: (key: string) => string | undefined | null | Promise<string | undefined | null>;
      setItem: (key: string, value: string) => void | Promise<void>;
      removeItem: (key: string) => void | Promise<void>;
   };
   onAuthStateChange?: (state: AuthState) => void;
   fetcher?: ApiFetcher;
   verbose?: boolean;
   verified?: boolean;
   data?: SubApiOptions<DataApiOptions>;
   auth?: SubApiOptions<AuthApiOptions>;
   media?: SubApiOptions<MediaApiOptions>;
   credentials?: RequestCredentials;
} & (
   | {
        token?: string;
        user?: TApiUser | null;
     }
   | {
        request: Request;
     }
);

export type AuthState = {
   token?: string;
   user?: TApiUser;
   verified: boolean;
};

export class Api {
   private token?: string;
   private user?: TApiUser;
   private verified = false;
   public token_transport: "header" | "cookie" | "none" = "header";

   public system!: SystemApi;
   public data!: DataApi;
   public auth!: AuthApi;
   public media!: MediaApi;

   constructor(public options: ApiOptions = {}) {
      // only mark verified if forced
      this.verified = options.verified === true;

      // prefer request if given
      if ("request" in options && options.request) {
         this.options.host = options.host ?? new URL(options.request.url).origin;
         this.options.headers = options.headers ?? options.request.headers;
         this.extractToken();

         // then check for a token
      } else if ("token" in options && options.token) {
         this.token_transport = "header";
         this.updateToken(options.token, { trigger: false });

         // then check for an user object
      } else if ("user" in options && options.user) {
         this.token_transport = "none";
         this.user = options.user;
         this.verified = options.verified !== false;
      } else {
         this.extractToken();
      }

      this.buildApis();
   }

   get fetcher() {
      return this.options.fetcher ?? fetch;
   }

   get baseUrl() {
      return this.options.host ?? "http://localhost";
   }

   get tokenKey() {
      return this.options.key ?? "auth";
   }

   private extractToken() {
      // if token has to be extracted, it's never verified
      this.verified = false;

      if (this.options.headers) {
         // try cookies
         const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
         if (cookieToken) {
            this.token_transport = "cookie";
            this.updateToken(cookieToken);
            return;
         }

         // try authorization header
         const headerToken = this.options.headers.get("authorization")?.replace("Bearer ", "");
         if (headerToken) {
            this.token_transport = "header";
            this.updateToken(headerToken);
            return;
         }
      } else if (this.storage) {
         this.storage.getItem(this.tokenKey).then((token) => {
            this.token_transport = "header";
            this.updateToken(token ? String(token) : undefined, {
               verified: true,
               trigger: false,
            });
         });
      }
   }

   /**
    * Make storage async to allow async storages even if sync given
    * @private
    */
   private get storage() {
      const storage = this.options.storage;
      return new Proxy(
         {},
         {
            get(_, prop) {
               return (...args: any[]) => {
                  const response = storage ? storage[prop](...args) : undefined;
                  if (response instanceof Promise) {
                     return response;
                  }
                  return {
                     // biome-ignore lint/suspicious/noThenProperty: it's a promise :)
                     then: (fn) => fn(response),
                  };
               };
            },
         },
      ) as any;
   }

   updateToken(
      token?: string,
      opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean },
   ) {
      this.token = token;
      this.verified = opts?.verified === true;

      if (token) {
         this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
      } else {
         this.user = undefined;
      }

      const emit = () => {
         if (opts?.trigger !== false) {
            this.options.onAuthStateChange?.(this.getAuthState());
         }
      };
      if (this.storage) {
         const key = this.tokenKey;

         if (token) {
            this.storage.setItem(key, token).then(emit);
         } else {
            this.storage.removeItem(key).then(emit);
         }
      } else {
         if (opts?.trigger !== false) {
            emit();
         }
      }

      if (opts?.rebuild) this.buildApis();
   }

   private markAuthVerified(verfied: boolean) {
      this.verified = verfied;
      this.options.onAuthStateChange?.(this.getAuthState());
      return this;
   }

   isAuthVerified(): boolean {
      return this.verified;
   }

   getAuthState(): AuthState {
      return {
         token: this.token,
         user: this.user,
         verified: this.verified,
      };
   }

   isAuthenticated(): boolean {
      const { token, user } = this.getAuthState();
      return !!token && !!user;
   }

   async getVerifiedAuthState(): Promise<AuthState> {
      await this.verifyAuth();
      return this.getAuthState();
   }

   async verifyAuth() {
      try {
         const { ok, data } = await this.auth.me();
         const user = data?.user;
         if (!ok || !user) {
            throw new Error();
         }

         this.user = user;
      } catch (e) {
         this.updateToken(undefined);
      } finally {
         this.markAuthVerified(true);
      }
   }

   getUser(): TApiUser | null {
      return this.user || null;
   }

   getParams() {
      return Object.freeze({
         host: this.baseUrl,
         token: this.token,
         headers: this.options.headers,
         token_transport: this.token_transport,
         verbose: this.options.verbose,
         credentials: this.options.credentials,
      });
   }

   private buildApis() {
      const baseParams = this.getParams();
      const fetcher = this.options.fetcher;

      this.system = new SystemApi(baseParams, fetcher);
      this.data = new DataApi(
         {
            ...baseParams,
            ...this.options.data,
         },
         fetcher,
      );
      this.auth = new AuthApi(
         {
            ...baseParams,
            ...this.options.auth,
            onTokenUpdate: (token, verified) => {
               this.updateToken(token, { rebuild: true, verified, trigger: true });
               this.options.auth?.onTokenUpdate?.(token);
            },
         },
         fetcher,
      );
      this.media = new MediaApi(
         {
            ...baseParams,
            ...this.options.media,
         },
         fetcher,
      );
   }
}

function getCookieValue(cookies: string | null, name: string) {
   if (!cookies) return null;

   for (const cookie of cookies.split("; ")) {
      const [key, value] = cookie.split("=");
      if (key === name && value) {
         return decodeURIComponent(value);
      }
   }
   return null;
}
