/*
Copyright 2022-2024 New Vector Ltd.
Copyright 2026 Element Creations 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 {
  type RTCCallIntent,
  type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import { pickBy } from "lodash-es";

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

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

export enum UserIntent {
  StartNewCall = "start_call",
  JoinExistingCall = "join_existing",
  StartNewCallVoice = "start_call_voice",
  JoinExistingCallVoice = "join_existing_voice",
  StartNewCallDM = "start_call_dm",
  StartNewCallDMVoice = "start_call_dm_voice",
  JoinExistingCallDM = "join_existing_dm",
  JoinExistingCallDMVoice = "join_existing_dm_voice",
  Unknown = "unknown",
}

export enum HeaderStyle {
  None = "none",
  Standard = "standard",
  AppBar = "app_bar",
}

export enum BackgroundStyle {
  Solid = "solid",
  Gradient = "gradient",
}

/**
 * The UrlProperties are used to pass required data to the widget.
 * Those are different in different rooms, users, devices. They do not configure the behavior of the
 * widget but provide the required data to the widget.
 */
export interface UrlProperties {
  // 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;
  /**
   * 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 to use end-to-end encryption.
   */
  e2eEnabled: boolean;
  /**
   * E2EE password
   */
  password: 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 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;
  /**
   * The theme to use for element call.
   * can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
   */
  theme: string | null;
  /**
   * The visual style of the page background.
   */
  background: BackgroundStyle;
}

/**
 * The configuration for the app, which can be set via URL parameters.
 * Those property are different to the UrlProperties, since they are all optional
 * and configure the behavior of the app. Their value is the same if EC is used in
 * the same context but with different accounts/users.
 *
 * Their defaults can be controlled by the `intent` property.
 */
export interface UrlConfiguration {
  /**
   * Whether the app should keep the user confined to the current call/room.
   */
  confineToRoom: 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;
  /**
   * The style of headers to show. "standard" is the default arrangement, "none"
   * hides the header entirely, and "app_bar" produces a header with a back
   * button like you might see in mobile apps. The callback for the back button
   * is window.controls.onBackButtonPressed.
   */
  header: HeaderStyle;
  /**
   * 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 the app is allowed to use fallback STUN servers for ICE in case the
   * user's homeserver doesn't provide any.
   */
  allowIceFallback: boolean;

  /**
   * 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;
  /**
   * Whether and what type of notification EC should send, when the user joins the call.
   */
  sendNotificationType?: RTCNotificationType;
  /**
   * Whether the app should automatically leave the call when there
   * is no one left in the call.
   * This is one part to make the call matrixRTC session behave like a telephone call.
   */
  autoLeaveWhenOthersLeft: boolean;

  /**
   * If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
   * This is a no-op if not combined with sendNotificationType.
   *
   * This entails:
   *  - show ui that it is awaiting an answer
   *  - play a sound that indicates that it is awaiting an answer
   *  - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
   */
  waitForCallPickup: boolean;

  /**
   * Whether to enable echo cancellation for audio capture.
   * Defaults to true.
   */
  echoCancellation?: boolean;
  /**
   * Whether to enable noise suppression for audio capture.
   * Defaults to true.
   */
  noiseSuppression?: boolean;

  callIntent?: RTCCallIntent;
}

// 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 extends UrlProperties, UrlConfiguration {}

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 getEnumParam<T extends string>(
    name: string,
    type: { [s: string]: T } | ArrayLike<T>,
  ): T | undefined {
    const value = this.getParam(name);
    if (value !== null && Object.values(type).includes(value as T)) {
      return value as T;
    }
    return undefined;
  }

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

  /**
   * Returns true if the flag exists and is not "false".
   */
  public getFlagParam(name: string, defaultValue = false): boolean {
    const param = this.getParam(name);
    return param === null ? defaultValue : param !== "false";
  }
  /**
   * Returns the value of the flag if it exists, or undefined if it does not.
   */
  public getFlag(name: string): boolean | undefined {
    const param = this.getParam(name);
    return param !== null ? param !== "false" : undefined;
  }
}

let urlParamCache: {
  search?: string;
  hash?: string;
  params?: UrlParams;
} = {};

/**
 * Gets the url params and loads them from a cache if already computed.
 * @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 => {
  if (
    urlParamCache.search === search &&
    urlParamCache.hash === hash &&
    urlParamCache.params
  ) {
    return urlParamCache.params;
  }
  const params = computeUrlParams(search, hash);
  urlParamCache = { search, hash, params };

  return params;
};

/**
 * 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 computeUrlParams = (search = "", hash = ""): UrlParams => {
  const parser = new ParamParser(search, hash);

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

  const widgetId = parser.getParam("widgetId");
  const parentUrl = parser.getParam("parentUrl");
  const isWidget = !!widgetId && !!parentUrl;

  /**
   * 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`.
   * This is a platform specific default set of parameters, that allows to minize the configuration
   * needed to start a call. And empowers the EC codebase to control the platform/intent behavior in
   * a central place.
   *
   * In short: either provide url query parameters of UrlConfiguration or set the intent
   * (or the global defaults will be used).
   */
  const intent = !isWidget
    ? UserIntent.Unknown
    : (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
  // Here we only use constants and `platform` to determine the intent preset.
  let intentPreset: UrlConfiguration = {
    confineToRoom: true,
    preload: false,
    header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
    showControls: true,
    hideScreensharing: false,
    allowIceFallback: true,
    perParticipantE2EE: true,
    controlledAudioDevices: platform === "desktop" ? false : true,
    skipLobby: true,
    returnToLobby: false,
    sendNotificationType: "notification",
    autoLeaveWhenOthersLeft: false,
    waitForCallPickup: false,
  };
  switch (intent) {
    case UserIntent.StartNewCall:
      intentPreset.skipLobby = false;
      intentPreset.callIntent = "video";
      break;
    case UserIntent.JoinExistingCall:
      // On desktop this will be overridden based on which button was used to join the call
      intentPreset.skipLobby = false;
      intentPreset.callIntent = "video";
      break;
    case UserIntent.StartNewCallVoice:
      intentPreset.skipLobby = false;
      intentPreset.callIntent = "audio";
      break;
    case UserIntent.JoinExistingCallVoice:
      // On desktop this will be overridden based on which button was used to join the call
      intentPreset.skipLobby = false;
      intentPreset.callIntent = "audio";
      break;
    case UserIntent.StartNewCallDMVoice:
      intentPreset.callIntent = "audio";
    // Fall through
    case UserIntent.StartNewCallDM:
      intentPreset.skipLobby = true;
      intentPreset.sendNotificationType = "ring";
      intentPreset.autoLeaveWhenOthersLeft = true;
      intentPreset.waitForCallPickup = true;
      intentPreset.callIntent = intentPreset.callIntent ?? "video";
      break;
    case UserIntent.JoinExistingCallDMVoice:
      intentPreset.callIntent = "audio";
    // Fall through
    case UserIntent.JoinExistingCallDM:
      // On desktop this will be overridden based on which button was used to join the call
      intentPreset.skipLobby = true;
      intentPreset.autoLeaveWhenOthersLeft = true;
      intentPreset.callIntent = intentPreset.callIntent ?? "video";
      break;
    // Non widget usecase defaults
    default:
      intentPreset = {
        confineToRoom: false,
        preload: false,
        header: HeaderStyle.Standard,
        showControls: true,
        hideScreensharing: false,
        allowIceFallback: false,
        perParticipantE2EE: false,
        controlledAudioDevices: false,
        skipLobby: false,
        returnToLobby: false,
        sendNotificationType: undefined,
        autoLeaveWhenOthersLeft: false,
        waitForCallPickup: false,
      };
  }

  const properties: UrlProperties = {
    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"),
    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,
    theme: parser.getParam("theme"),
    background:
      parser.getEnumParam("background", BackgroundStyle) ??
      BackgroundStyle.Gradient,
    viaServers: !isWidget ? parser.getParam("viaServers") : null,
    homeserver: !isWidget ? parser.getParam("homeserver") : null,
    posthogApiHost: parser.getParam("posthogApiHost"),
    posthogApiKey: parser.getParam("posthogApiKey"),
    posthogUserId: parser.getParam("posthogUserId"),
    rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
    sentryDsn: parser.getParam("sentryDsn"),
    sentryEnvironment: parser.getParam("sentryEnvironment"),
    e2eEnabled: parser.getFlagParam("enableE2EE", true),
  };

  const configuration: Partial<UrlConfiguration> = {
    confineToRoom: parser.getFlag("confineToRoom"),
    preload: isWidget ? parser.getFlag("preload") : undefined,
    // Check hideHeader for backwards compatibility. If header is set, hideHeader
    // is ignored.
    header: parser.getEnumParam("header", HeaderStyle),
    showControls: parser.getFlag("showControls"),
    hideScreensharing: parser.getFlag("hideScreensharing"),
    allowIceFallback: parser.getFlag("allowIceFallback"),
    perParticipantE2EE: parser.getFlag("perParticipantE2EE"),
    controlledAudioDevices: parser.getFlag("controlledAudioDevices"),
    skipLobby: isWidget ? parser.getFlag("skipLobby") : false,
    // 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.getFlag("returnToLobby") : false,
    sendNotificationType: parser.getEnumParam("sendNotificationType", [
      "ring",
      "notification",
    ]),
    waitForCallPickup: parser.getFlag("waitForCallPickup"),
    autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
    noiseSuppression: parser.getFlagParam("noiseSuppression", true),
    echoCancellation: parser.getFlagParam("echoCancellation", true),
  };

  // Log the final configuration for debugging purposes.
  // This will only log when the cache is not yet set.
  logger.info(
    "UrlParams: final set of url params\n",
    "intent:",
    intent,
    "\nproperties:",
    redact(properties, "password"),
    "configuration:",
    configuration,
  );

  return {
    ...properties,
    ...intentPreset,
    ...pickBy(configuration, (v?: unknown) => v !== undefined),
  };
};

/**
 * 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 !== null) {
    // Replace any non-printable characters that another client may have inserted.
    // For instance on iOS, some copied links end up with zero width characters on the end which get encoded into the URL.
    // This isn't valid for a roomId, so we can freely strip the content.
    roomId = roomId.replaceAll(/^[^ -~]+|[^ -~]+$/g, "");
    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;
}
