import { getServiceEndpointForHandle } from "/js/atproto.js";
import {
  OauthClient,
  HandleNotFoundError,
  InvalidAuthUrlError,
} from "/js/oauth.js";
import { isDev, isNative, TimeoutError, withTimeout } from "/js/utils.js";
import { linkToLogin, validateReturnToParam } from "/js/navigation.js";

export class RefreshTokenError extends Error {
  constructor(res) {
    super("Refresh token error");
    this.res = res;
  }
}

export class InvalidUsernameError extends Error {
  constructor(message) {
    super(message);
    this.name = "InvalidUsernameError";
  }
}

export class AuthError extends Error {
  constructor(message) {
    super(message);
    this.name = "AuthError";
  }
}

export function getLoginErrorMessage(error) {
  if (error instanceof TimeoutError) return "Request timed out";
  if (error instanceof InvalidUsernameError) return "Invalid username";
  if (error instanceof AuthError) return "Authorization failed";
  console.error(error);
  return "Failed to sign in";
}

function parseJwt(token) {
  try {
    // Split the token into its three parts
    const parts = token.split(".");
    if (parts.length !== 3) {
      throw new Error("Invalid JWT format");
    }

    // Decode the payload (second part)
    const base64Url = parts[1];
    const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
    const jsonPayload = decodeURIComponent(
      atob(base64)
        .split("")
        .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
        .join(""),
    );

    return JSON.parse(jsonPayload);
  } catch (error) {
    console.error("Failed to decode JWT:", error);
    return null;
  }
}

export class BasicAuthSession {
  constructor(accessJwt, refreshJwt) {
    this.accessJwt = accessJwt;
    this.refreshJwt = refreshJwt;
  }

  save() {
    localStorage.setItem("accessJwt", this.accessJwt);
    localStorage.setItem("refreshJwt", this.refreshJwt);
  }

  get serviceEndpoint() {
    // Decode the accessJwt to get the serviceEndpoint
    const decoded = parseJwt(this.accessJwt);
    return decoded.aud.replace("did:web:", "https://");
  }

  get did() {
    const decoded = parseJwt(this.accessJwt);
    return decoded.sub;
  }

  get handle() {
    // TODO: persist handle on login so plugins can read it under basic auth
    return null;
  }

  async fetch(url, options) {
    const res = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        Authorization: `Bearer ${this.accessJwt}`,
      },
    });
    // refresh the token if needed
    if (res.status === 400) {
      const error = await res.json();
      if (error.error === "ExpiredToken") {
        const refreshRes = await fetch(
          this.serviceEndpoint + "/xrpc/com.atproto.server.refreshSession",
          {
            method: "POST",
            headers: {
              Authorization: `Bearer ${this.refreshJwt}`,
            },
          },
        );
        if (refreshRes.ok) {
          const data = await refreshRes.json();
          this.accessJwt = data.accessJwt;
          this.refreshJwt = data.refreshJwt;
          this.save();
          return await this.fetch(url, options);
        } else {
          throw new RefreshTokenError(refreshRes);
        }
      }
    }
    return res;
  }

  async delete() {
    localStorage.removeItem("accessJwt");
    localStorage.removeItem("refreshJwt");
  }

  static fromLocalStorage() {
    const accessJwt = localStorage.getItem("accessJwt");
    const refreshJwt = localStorage.getItem("refreshJwt");
    if (!accessJwt || !refreshJwt) {
      return null;
    }
    return new BasicAuthSession(accessJwt, refreshJwt);
  }
}

export class BasicAuthProvider {
  constructor() {
    this.session = null;
    this._loaded = false;
  }

  async getSession() {
    if (!this._loaded) {
      this.session = BasicAuthSession.fromLocalStorage();
      this._loaded = true;
    }
    return this.session;
  }

  async login({ handle, password }) {
    const serviceEndpoint = await getServiceEndpointForHandle(handle);
    const res = await fetch(
      serviceEndpoint + "/xrpc/com.atproto.server.createSession",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ identifier: handle, password }),
      },
    );
    if (!res.ok) {
      throw new Error("Login failed");
    }
    const data = await res.json();
    const session = new BasicAuthSession(data.accessJwt, data.refreshJwt);
    this.session = session;
    this._loaded = true;
    this.session.save();
    return session;
  }

  async logout(did = null) {
    const session = await this.getSession();
    if (!session) return;
    await session.delete();
    this.session = null;
  }

  async listAccounts() {
    const session = await this.getSession();
    if (!session) return [];
    return [
      {
        did: session.did,
        handle: session.handle,
        pdsUrl: null,
        needsReauth: false,
      },
    ];
  }

  supportsMultipleAccounts() {
    return false;
  }
}

export class OAuthProvider {
  constructor() {
    this._client = null;
  }

  async getClient() {
    if (!this._client) {
      const proxyClientId = window.env.oatproxyClientId || null;
      const proxyOrigin = proxyClientId ? new URL(proxyClientId).origin : null;
      const baseRedirect = `https://${window.env.hostName}/callback.html`;
      const redirectUri =
        proxyOrigin && new URL(baseRedirect).origin !== proxyOrigin
          ? `${proxyOrigin}?oatproxyActualRedirect=${encodeURIComponent(baseRedirect)}`
          : baseRedirect;
      this._client = await OauthClient.load({
        clientId:
          proxyClientId ??
          `https://${window.env.hostName}/oauth-client-metadata.json`,
        redirectUri,
        proxyUrl: proxyOrigin,
      });
    }
    return this._client;
  }

  async getSession() {
    const client = await this.getClient();
    return client.getSession();
  }

  async login({ handle, returnTo }) {
    const client = await this.getClient();
    let authUrl = null;
    try {
      authUrl = await client.getAuthorizationUrl(handle, {
        scope: window.env.oauthScopes,
        state: { loopback: isDev(), returnTo: returnTo ?? null },
      });
    } catch (error) {
      if (error instanceof HandleNotFoundError) {
        throw new InvalidUsernameError("Invalid username");
      } else if (error instanceof InvalidAuthUrlError) {
        throw new AuthError("Invalid authorization URL: " + error.message);
      }
      throw error;
    }
    window.location.href = authUrl;
    return new Promise(() => {}); // no resolve, just wait for redirect
    // if (isNative()) {
    //   // Listen for the native app callback
    //   App.addListener("appUrlOpen", (state) => {
    //     if (data.url.startsWith("dev.pages.impro:/callback")) {
    //       const params = data.url.split("?")[1];
    //       window.location.href = "/callback.html?" + params;
    //     }
    //   });
    //   await Browser.open({ url: authUrl });
    // } else {
    //   window.location.href = authUrl;
    // }
  }

  async logout(did = null) {
    const client = await this.getClient();
    await client.revoke(did);
  }

  async listAccounts() {
    const client = await this.getClient();
    return client.listAccounts();
  }

  async switchToAccount(did) {
    const client = await this.getClient();
    client.switchToAccount(did);
  }

  async removeAccount(did) {
    const client = await this.getClient();
    await client.removeAccount(did);
  }

  supportsMultipleAccounts() {
    return true;
  }

  async handleOauthCallback(url) {
    const params = new URLSearchParams(url.split("?")[1]);
    const code = params.get("code");
    const state = params.get("state");
    const iss = params.get("iss");
    if (!code || !state || !iss) {
      throw new Error("Missing code, state, or iss in callback");
    }
    const client = await this.getClient();
    return await client.handleCallback({ code, state, iss });
  }
}

const FORCE_LOGOUT_QUERY_PARAM = "force-logout";

export function getMissingScopes(
  grantedScope,
  requiredScope,
  optionalScope = "",
) {
  const granted = new Set(grantedScope.split(/\s+/).filter(Boolean));
  const optional = new Set(optionalScope.split(/\s+/).filter(Boolean));
  return requiredScope
    .split(/\s+/)
    .filter(Boolean)
    .filter((scope) => !granted.has(scope) && !optional.has(scope));
}

export class Auth {
  constructor(provider) {
    if (!provider) {
      throw new Error("Auth requires a provider");
    }
    this.provider = provider;
  }

  getSession() {
    return this.provider.getSession();
  }

  logout(did = null) {
    return this.provider.logout(did);
  }

  async login(args, { timeout = 10000 } = {}) {
    const call = () => this.provider.login(args);
    if (timeout) {
      return withTimeout(call, timeout);
    }
    return call();
  }

  supportsMultipleAccounts() {
    return this.provider.supportsMultipleAccounts?.() ?? false;
  }

  async listAccounts() {
    const accounts = (await this.provider.listAccounts?.()) ?? [];
    const requiredScope = window.env?.oauthScopes ?? "";
    const optionalScope = window.env?.oauthOptionalScopes ?? "";
    // Mark accounts with out-of-date scopes as "needsReauth"
    return accounts.map((account) => {
      const scopesOutOfDate =
        getMissingScopes(account.scope ?? "", requiredScope, optionalScope)
          .length > 0;
      return {
        ...account,
        needsReauth: account.needsReauth || scopesOutOfDate,
      };
    });
  }

  async switchAccount(did) {
    if (!this.provider.switchToAccount) {
      throw new Error("Account switching is not supported");
    }
    await this.provider.switchToAccount(did);
    window.location.reload();
    return new Promise(() => {});
  }

  // Drop a stored account. If removing the current one, flip to another
  // account first (or fall back to a full logout) and reload.
  async removeAccount(did) {
    if (!this.provider.removeAccount) {
      throw new Error("Account removal is not supported");
    }
    const accounts = await this.listAccounts();
    const current = await this.provider.getSession();
    const isCurrent = current?.did === did;
    if (isCurrent) {
      const other = accounts.find((account) => account.did !== did);
      if (other) {
        await this.provider.switchToAccount(other.did);
        await this.provider.removeAccount(did);
        window.location.reload();
        return new Promise(() => {});
      }
      await this.provider.removeAccount(did);
      window.location.href = linkToLogin();
      return new Promise(() => {});
    }
    await this.provider.removeAccount(did);
  }

  async requireAuth() {
    const session = await this.provider.getSession();
    if (!session) {
      window.location.href = linkToLogin();
      return new Promise(() => {});
    }
    return session;
  }

  async requireNoAuth() {
    const params = new URLSearchParams(window.location.search);
    // When adding an account, allow the login screen to render even though a
    // session already exists. The OAuth callback will flip the active account
    // to the newly-added one.
    if (params.get("addAccount") === "1") {
      return null;
    }
    const session = await this.provider.getSession();
    if (session) {
      const returnTo = validateReturnToParam(params.get("returnTo"));
      window.location.href = returnTo ?? "/";
      return new Promise(() => {});
    }
    return null;
  }

  // Check for scope, ignoring query params
  async hasScope(scope) {
    const session = await this.provider.getSession();
    if (!session) return false;
    if (!session.scope) return true;
    return session.scope
      .split(/\s+/)
      .some((granted) => granted === scope || granted.startsWith(scope + "?"));
  }

  async ensureCurrentScopes() {
    const session = await this.provider.getSession();
    if (!session?.scope) return;
    const missing = getMissingScopes(
      session.scope,
      window.env.oauthScopes,
      window.env.oauthOptionalScopes ?? "",
    );
    if (missing.length === 0) return;
    console.warn("OAuth scopes are out of date, forcing re-auth:", missing);
    try {
      await this.logout();
    } catch (error) {
      console.error(error);
    }
    window.location.href = linkToLogin();
    return new Promise(() => {});
  }

  async handleForceLogoutParam() {
    const params = new URLSearchParams(window.location.search);
    if (!params.has(FORCE_LOGOUT_QUERY_PARAM)) {
      return;
    }
    try {
      await this.logout();
    } catch (error) {
      console.error(error);
    }
    // Strip the param before building returnTo so login doesn't redirect back into a logout loop
    params.delete(FORCE_LOGOUT_QUERY_PARAM);
    const cleanSearch = params.size ? "?" + params.toString() : "";
    window.history.replaceState(
      null,
      "",
      window.location.pathname + cleanSearch + window.location.hash,
    );
    window.location.href = linkToLogin();
    return new Promise(() => {});
  }
}

export const auth = new Auth(
  isNative() ? new BasicAuthProvider() : new OAuthProvider(),
);
