/*
Copyright 2022-2024 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";

import { Config } from "./config/Config";
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
import { E2eeType } from "./e2ee/e2eeType";

interface RoomIdentifier {
  roomAlias: string | null;
  roomId: string | null;
  viaServers: string[];
}

export enum UserIntent {
  StartNewCall = "start_call",
  JoinExistingCall = "join_existing",
  Unknown = "unknown",
}

// If you need to add a new flag to this interface, prefer a name that describes
// a specific behavior (such as 'confineToRoom'), rather than one that describes
// the situations that call for this behavior ('isEmbedded'). This makes it
// clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers.
export interface UrlParams {
  // Widget api related params
  widgetId: string | null;
  parentUrl: string | null;
  /**
   * Anything about what room we're pointed to should be from useRoomIdentifier which
   * parses the path and resolves alias with respect to the default server name, however
   * roomId is an exception as we need the room ID in embedded (matroyska) mode, and not
   * the room alias (or even the via params because we are not trying to join it). This
   * is also not validated, where it is in useRoomIdentifier().
   */
  roomId: string | null;
  /**
   * Whether the app should keep the user confined to the current call/room.
   */
  confineToRoom: boolean;
  /**
   * Whether upon entering a room, the user should be prompted to launch the
   * native mobile app. (Affects only Android and iOS.)
   *
   * The app prompt must also be enabled in the config for this to take effect.
   */
  appPrompt: boolean;
  /**
   * Whether the app should pause before joining the call until it sees an
   * io.element.join widget action, allowing it to be preloaded.
   */
  preload: boolean;
  /**
   * Whether to hide the room header when in a call.
   */
  hideHeader: boolean;
  /**
   * Whether the controls should be shown. For screen recording no controls can be desired.
   */
  showControls: boolean;
  /**
   * Whether to hide the screen-sharing button.
   */
  hideScreensharing: boolean;
  /**
   * Whether to use end-to-end encryption.
   */
  e2eEnabled: boolean;
  /**
   * The user's ID (only used in matryoshka mode).
   */
  userId: string | null;
  /**
   * The display name to use for auto-registration.
   */
  displayName: string | null;
  /**
   * The device's ID (only used in matryoshka mode).
   */
  deviceId: string | null;
  /**
   * The base URL of the homeserver to use for media lookups in matryoshka mode.
   */
  baseUrl: string | null;
  /**
   * The BCP 47 code of the language the app should use.
   */
  lang: string | null;
  /**
   * The fonts which the interface should use, if not empty.
   */
  fonts: string[];
  /**
   * The factor by which to scale the interface's font size.
   */
  fontScale: number | null;
  /**
   * The Posthog analytics ID. It is only available if the user has given consent for sharing telemetry in element web.
   */
  posthogUserId: string | null;
  /**
   * The Posthog API host. This is only used in the embedded package of Element Call.
   */
  posthogApiHost: string | null;
  /**
   * The Posthog API key. This is only used in the embedded package of Element Call.
   */
  posthogApiKey: string | null;
  /**
   * Whether the app is allowed to use fallback STUN servers for ICE in case the
   * user's homeserver doesn't provide any.
   */
  allowIceFallback: boolean;
  /**
   * E2EE password
   */
  password: string | null;
  /**
   * Whether the app should use per participant keys for E2EE.
   */
  perParticipantE2EE: boolean;
  /**
   * Whether the global JS controls for audio output devices should be enabled,
   * allowing the list of output devices to be controlled by the app hosting
   * Element Call.
   */
  controlledAudioDevices: boolean;
  /**
   * Setting this flag skips the lobby and brings you in the call directly.
   * In the widget this can be combined with preload to pass the device settings
   * with the join widget action.
   */
  skipLobby: boolean;
  /**
   * Setting this flag makes element call show the lobby after leaving a call.
   * This is useful for video rooms.
   */
  returnToLobby: boolean;
  /**
   * The theme to use for element call.
   * can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
   */
  theme: string | null;
  /** This defines the homeserver that is going to be used when joining a room.
   * It has to be set to a non default value for links to rooms
   * that are not on the default homeserver,
   * that is in use for the current user.
   */
  viaServers: string | null;
  /**
   * This defines the homeserver that is going to be used when registering
   * a new (guest) user.
   * This can be user to configure a non default guest user server when
   * creating a spa link.
   */
  homeserver: string | null;

  /**
   * The user's intent with respect to the call.
   * e.g. if they clicked a Start Call button, this would be `start_call`.
   * If it was a Join Call button, it would be `join_existing`.
   */
  intent: string | null;

  /**
   * The rageshake submit URL. This is only used in the embedded package of Element Call.
   */
  rageshakeSubmitUrl: string | null;

  /**
   * The Sentry DSN. This is only used in the embedded package of Element Call.
   */
  sentryDsn: string | null;

  /**
   * The Sentry environment. This is only used in the embedded package of Element Call.
   */
  sentryEnvironment: string | null;
}

// This is here as a stopgap, but what would be far nicer is a function that
// takes a UrlParams and returns a query string. That would enable us to
// consolidate all the data about URL parameters and their meanings to this one
// file.
export function editFragmentQuery(
  hash: string,
  edit: (params: URLSearchParams) => URLSearchParams,
): string {
  const fragmentQueryStart = hash.indexOf("?");
  const fragmentParams = edit(
    new URLSearchParams(
      fragmentQueryStart === -1 ? "" : hash.substring(fragmentQueryStart),
    ),
  );
  return `${hash.substring(
    0,
    fragmentQueryStart,
  )}?${fragmentParams.toString()}`;
}

class ParamParser {
  private fragmentParams: URLSearchParams;
  private queryParams: URLSearchParams;

  public constructor(search: string, hash: string) {
    this.queryParams = new URLSearchParams(search);

    const fragmentQueryStart = hash.indexOf("?");
    this.fragmentParams = new URLSearchParams(
      fragmentQueryStart === -1 ? "" : hash.substring(fragmentQueryStart),
    );
  }

  // Normally, URL params should be encoded in the fragment so as to avoid
  // leaking them to the server. However, we also check the normal query
  // string for backwards compatibility with versions that only used that.
  public getParam(name: string): string | null {
    return this.fragmentParams.get(name) ?? this.queryParams.get(name);
  }

  public getAllParams(name: string): string[] {
    return [
      ...this.fragmentParams.getAll(name),
      ...this.queryParams.getAll(name),
    ];
  }

  public getFlagParam(name: string, defaultValue = false): boolean {
    const param = this.getParam(name);
    return param === null ? defaultValue : param !== "false";
  }
}

/**
 * Gets the app parameters for the current URL.
 * @param search The URL search string
 * @param hash The URL hash
 * @returns The app parameters encoded in the URL
 */
export const getUrlParams = (
  search = window.location.search,
  hash = window.location.hash,
): UrlParams => {
  const parser = new ParamParser(search, hash);

  const fontScale = parseFloat(parser.getParam("fontScale") ?? "");

  let intent = parser.getParam("intent");
  if (!intent || !Object.values(UserIntent).includes(intent as UserIntent)) {
    intent = UserIntent.Unknown;
  }
  const widgetId = parser.getParam("widgetId");
  const parentUrl = parser.getParam("parentUrl");
  const isWidget = !!widgetId && !!parentUrl;

  return {
    widgetId,
    parentUrl,

    // NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
    // what would we do if it were invalid? If the widget API says that's what
    // the room ID is, then that's what it is.
    roomId: parser.getParam("roomId"),
    password: parser.getParam("password"),
    // This flag has 'embed' as an alias for historical reasons
    confineToRoom:
      parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"),
    appPrompt: parser.getFlagParam("appPrompt", true),
    preload: isWidget ? parser.getFlagParam("preload") : false,
    hideHeader: parser.getFlagParam("hideHeader"),
    showControls: parser.getFlagParam("showControls", true),
    hideScreensharing: parser.getFlagParam("hideScreensharing"),
    e2eEnabled: parser.getFlagParam("enableE2EE", true),
    userId: isWidget ? parser.getParam("userId") : null,
    displayName: parser.getParam("displayName"),
    deviceId: isWidget ? parser.getParam("deviceId") : null,
    baseUrl: isWidget ? parser.getParam("baseUrl") : null,
    lang: parser.getParam("lang"),
    fonts: parser.getAllParams("font"),
    fontScale: Number.isNaN(fontScale) ? null : fontScale,
    allowIceFallback: parser.getFlagParam("allowIceFallback"),
    perParticipantE2EE: parser.getFlagParam("perParticipantE2EE"),
    controlledAudioDevices: parser.getFlagParam(
      "controlledAudioDevices",
      // the deprecated property name
      parser.getFlagParam("controlledMediaDevices"),
    ),
    skipLobby: parser.getFlagParam(
      "skipLobby",
      isWidget && intent === UserIntent.StartNewCall,
    ),
    // In SPA mode the user should always exit to the home screen when hanging
    // up, rather than being sent back to the lobby
    returnToLobby: isWidget ? parser.getFlagParam("returnToLobby") : false,
    theme: parser.getParam("theme"),
    viaServers: !isWidget ? parser.getParam("viaServers") : null,
    homeserver: !isWidget ? parser.getParam("homeserver") : null,
    intent,
    posthogApiHost: parser.getParam("posthogApiHost"),
    posthogApiKey: parser.getParam("posthogApiKey"),
    posthogUserId:
      parser.getParam("posthogUserId") ?? parser.getParam("analyticsID"),
    rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
    sentryDsn: parser.getParam("sentryDsn"),
    sentryEnvironment: parser.getParam("sentryEnvironment"),
  };
};

/**
 * Hook to simplify use of getUrlParams.
 * @returns The app parameters for the current URL
 */
export const useUrlParams = (): UrlParams => {
  const { search, hash } = useLocation();
  return useMemo(() => getUrlParams(search, hash), [search, hash]);
};

export function getRoomIdentifierFromUrl(
  pathname: string,
  search: string,
  hash: string,
): RoomIdentifier {
  let roomAlias: string | null = null;
  pathname = pathname.substring(1); // Strip the "/"
  const pathComponents = pathname.split("/");
  const pathHasRoom = pathComponents[0] == "room";
  const hasRoomAlias = pathComponents.length > 1;

  // What type is our url: roomAlias in hash, room alias as the search path, roomAlias after /room/
  if (hash === "" || hash.startsWith("#?")) {
    if (hasRoomAlias && pathHasRoom) {
      roomAlias = pathComponents[1];
    }
    if (!pathHasRoom) {
      roomAlias = pathComponents[0];
    }
  } else {
    roomAlias = hash;
  }

  // Delete "?" and what comes afterwards
  roomAlias = roomAlias?.split("?")[0] ?? null;

  if (roomAlias) {
    // Make roomAlias is null, if it only is a "#"
    if (roomAlias.length <= 1) {
      roomAlias = null;
    } else {
      // Add "#", if not present
      if (!roomAlias.startsWith("#")) {
        roomAlias = `#${roomAlias}`;
      }
      // Add server part, if not present
      if (!roomAlias.includes(":")) {
        roomAlias = `${roomAlias}:${Config.defaultServerName()}`;
      }
    }
  }

  const parser = new ParamParser(search, hash);

  // Make sure roomId is valid
  let roomId: string | null = parser.getParam("roomId");
  if (!roomId?.startsWith("!")) {
    roomId = null;
  } else if (!roomId.includes("")) {
    roomId = null;
  }

  return {
    roomAlias,
    roomId,
    viaServers: parser.getAllParams("viaServers"),
  };
}

export const useRoomIdentifier = (): RoomIdentifier => {
  const { pathname, search, hash } = useLocation();
  return useMemo(
    () => getRoomIdentifierFromUrl(pathname, search, hash),
    [pathname, search, hash],
  );
};

export function generateUrlSearchParams(
  roomId: string,
  encryptionSystem: EncryptionSystem,
  viaServers?: string[],
): URLSearchParams {
  const params = new URLSearchParams();
  // The password shouldn't need URL encoding here (we generate URL-safe ones) but encode
  // it in case it came from another client that generated a non url-safe one
  switch (encryptionSystem?.kind) {
    case E2eeType.SHARED_KEY: {
      const encodedPassword = encodeURIComponent(encryptionSystem.secret);
      if (encodedPassword !== encryptionSystem.secret) {
        logger.info(
          "Encoded call password used non URL-safe chars: buggy client?",
        );
      }
      params.set("password", encodedPassword);
      break;
    }
    case E2eeType.PER_PARTICIPANT:
      params.set("perParticipantE2EE", "true");
      break;
  }
  params.set("roomId", roomId);
  viaServers?.forEach((s) => params.set("viaServers", s));

  return params;
}
