// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { v4 as uuidv4 } from "uuid";
import {
  TrouterMessage,
  MessageHandler,
  HandleMessageResult,
  LogProvider,
  ITelemetrySender,
  TelemetryEvent,
} from "@skype/tstrouter";
import { AzureLogger } from "@azure/logger";
import {
  MessageReceivedPayload,
  MessageEditedPayload,
  MessageDeletedPayload,
  TypingIndicatorReceivedPayload,
  ReadReceiptReceivedPayload,
  ReadReceiptMessageBody,
  ChatThreadCreatedPayload,
  ChatThreadDeletedPayload,
  ChatThreadPropertiesUpdatedPayload,
  ParticipantsAddedPayload,
  ParticipantsRemovedPayload,
  ChatParticipantPayload,
  ChatThreadPropertiesPayload,
} from "./TrouterNotificationPayload";
import {
  ChatEventId,
  ChatMessageReceivedEvent,
  ChatMessageEditedEvent,
  ChatMessageDeletedEvent,
  ReadReceiptReceivedEvent,
  TypingIndicatorReceivedEvent,
  ChatThreadCreatedEvent,
  ChatThreadDeletedEvent,
  ChatThreadPropertiesUpdatedEvent,
  ParticipantsAddedEvent,
  ParticipantsRemovedEvent,
  ChatParticipant,
  ChatThreadProperties,
  ChatAttachment,
  ChatRetentionPolicy,
  DeleteReason,
} from "./events/chat";
import {
  CommunicationUserKind,
  PhoneNumberKind,
  MicrosoftTeamsUserKind,
  UnknownIdentifierKind,
} from "./events/identifierModels";
import { CommunicationTokenCredential } from "./SignalingClient";
import { isNodeLike } from "@azure/core-util";
import { CloudPrefix, CloudType, EudbCountries } from "./constants";

const eventIds = new Map<ChatEventId, number>([
  ["chatMessageReceived", 200],
  ["typingIndicatorReceived", 245],
  ["readReceiptReceived", 246],
  ["chatMessageEdited", 247],
  ["chatMessageDeleted", 248],
  ["chatThreadCreated", 257],
  ["chatThreadPropertiesUpdated", 258],
  ["chatThreadDeleted", 259],
  ["participantsAdded", 260],
  ["participantsRemoved", 261],
]);

const publicTeamsUserPrefix = "8:orgid:";
const dodTeamsUserPrefix = "8:dod:";
const gcchTeamsUserPrefix = "8:gcch:";
const teamsVisitorUserPrefix = "8:teamsvisitor:";
const phoneNumberPrefix = "4:";
const acsUserPrefix = "8:acs:";
const acsGcchUserPrefix = "8:gcch-acs:";
const acsDodUserPrefix = "8:dod-acs:";
const spoolUserPrefix = "8:spool:";

export const toMessageHandler = (
  event: ChatEventId,
  listener: (payload: any) => any,
  resourceEndpoint: string,
  gatewayApiVersion: string
): MessageHandler => {
  const eventId = eventIds.get(event);
  return {
    handleMessage(message: TrouterMessage): HandleMessageResult | undefined {
      let genericPayload = null;
      if (message?.rawBody) {
        genericPayload = JSON.parse(message.rawBody);
      }
      if (genericPayload === null || genericPayload.eventId !== eventId) {
        return undefined;
      }
      const eventPayload = toEventPayload(
        event,
        genericPayload,
        resourceEndpoint,
        gatewayApiVersion
      );
      if (eventPayload === null) {
        return undefined;
      }
      listener(eventPayload);
      return { isHandled: true, resultCode: 200 };
    },
  };
};

function toChatMessageReceivedEvent<T extends ChatMessageReceivedEvent>(
  payload: MessageReceivedPayload,
  resourceEndpoint: string,
  gatewayApiVersion: string
): T {
  return {
    threadId: payload.groupId,
    sender: constructIdentifierKindFromMri(payload.senderId),
    senderDisplayName: payload.senderDisplayName,
    recipient: constructIdentifierKindFromMri(payload.recipientMri),
    id: payload.messageId,
    createdOn: new Date(payload.originalArrivalTime),
    version: payload.version,
    type: payload.messageType,
    message: payload.messageBody,
    metadata: (parseJsonString(payload.acsChatMessageMetadata) as Record<string, string>) || {},
    attachments: transformEndpoint(
      (parseJsonString(payload.attachments) as ChatAttachment[]) || [],
      resourceEndpoint,
      gatewayApiVersion
    ),
  } as T;
}

function toChatMessageEditedEvent<T extends ChatMessageEditedEvent, P extends MessageEditedPayload>(
  payload: P,
  resourceEndpoint: string,
  gatewayApiVersion: string
): T {
  return {
    ...toChatMessageReceivedEvent(payload, resourceEndpoint, gatewayApiVersion),
    editedOn: new Date(payload.edittime),
  };
}

const toEventPayload = (
  event: ChatEventId,
  genericPayload: any,
  resourceEndpoint: string,
  gatewayApiVersion: string
): any => {
  if (event === "chatMessageReceived") {
    const payload = genericPayload as MessageReceivedPayload;
    return toChatMessageReceivedEvent(payload, resourceEndpoint, gatewayApiVersion);
  }

  if (event === "chatMessageEdited") {
    const payload = genericPayload as MessageEditedPayload;
    return toChatMessageEditedEvent(payload, resourceEndpoint, gatewayApiVersion);
  }

  if (event === "chatMessageDeleted") {
    const payload = genericPayload as MessageDeletedPayload;
    const eventPayload: ChatMessageDeletedEvent = {
      threadId: payload.groupId,
      sender: constructIdentifierKindFromMri(payload.senderId),
      senderDisplayName: payload.senderDisplayName,
      recipient: constructIdentifierKindFromMri(payload.recipientMri),
      id: payload.messageId,
      createdOn: new Date(payload.originalArrivalTime),
      version: payload.version,
      deletedOn: new Date(payload.deletetime),
      type: payload.messageType,
    };
    return eventPayload;
  }

  if (event === "typingIndicatorReceived") {
    const payload = genericPayload as TypingIndicatorReceivedPayload;
    const eventPayload: TypingIndicatorReceivedEvent = {
      threadId: payload.groupId,
      sender: constructIdentifierKindFromMri(payload.senderId),
      senderDisplayName: payload.senderDisplayName,
      recipient: constructIdentifierKindFromMri(payload.recipientMri),
      version: payload.version,
      receivedOn: new Date(payload.originalArrivalTime),
    };
    return eventPayload;
  }

  if (event === "readReceiptReceived") {
    const payload = genericPayload as ReadReceiptReceivedPayload;
    const readReceiptMessageBody = JSON.parse(payload.messageBody) as ReadReceiptMessageBody;
    const consumptionHorizon = readReceiptMessageBody.consumptionhorizon.split(";");
    const eventPayload: ReadReceiptReceivedEvent = {
      threadId: payload.groupId,
      sender: constructIdentifierKindFromMri(payload.senderId),
      senderDisplayName: "",
      recipient: constructIdentifierKindFromMri(payload.recipientMri),
      chatMessageId: payload.messageId,
      readOn: new Date(+consumptionHorizon[1]),
    };
    return eventPayload;
  }

  if (event === "chatThreadCreated") {
    const payload = genericPayload as ChatThreadCreatedPayload;
    const createdByPayload = JSON.parse(unescape(payload.createdBy)) as ChatParticipantPayload;
    const membersPayload = JSON.parse(unescape(payload.members)) as ChatParticipantPayload[];
    const createdBy = toChatParticipant(createdByPayload);
    const chatParticipants: ChatParticipant[] = membersPayload.map((m) => {
      return toChatParticipant(m);
    });
    const eventPayload: ChatThreadCreatedEvent = {
      threadId: payload.threadId,
      createdOn: new Date(payload.createTime),
      createdBy: createdBy,
      version: payload.version,
      participants: chatParticipants,
      properties: toThreadProperties(
        JSON.parse(unescape(payload.properties)) as ChatThreadPropertiesPayload
      ),
      retentionPolicy: getRetentionPolicy(
        JSON.parse(unescape(payload.properties)) as ChatThreadPropertiesPayload
      ),
    };
    return eventPayload;
  }

  if (event === "chatThreadPropertiesUpdated") {
    const payload = genericPayload as ChatThreadPropertiesUpdatedPayload;
    const updatedByPayload = JSON.parse(unescape(payload.editedBy)) as ChatParticipantPayload;
    const updatedBy = toChatParticipant(updatedByPayload);
    const eventPayload: ChatThreadPropertiesUpdatedEvent = {
      threadId: payload.threadId,
      updatedOn: new Date(payload.editTime),
      updatedBy: updatedBy,
      version: payload.version,
      properties: toThreadProperties(
        JSON.parse(unescape(payload.properties)) as ChatThreadPropertiesPayload
      ),
      retentionPolicy: getRetentionPolicy(
        JSON.parse(unescape(payload.properties)) as ChatThreadPropertiesPayload
      ),
    };
    return eventPayload;
  }

  if (event === "chatThreadDeleted") {
    const payload = genericPayload as ChatThreadDeletedPayload;
    const deletedBy =
      genericPayload.reason == DeleteReason.DeletedByPolicy
        ? null
        : toChatParticipant(JSON.parse(unescape(payload.deletedBy)) as ChatParticipantPayload);
    const eventPayload: ChatThreadDeletedEvent = {
      threadId: payload.threadId,
      deletedOn: new Date(payload.deleteTime),
      deletedBy: deletedBy,
      version: payload.version,
      reason: payload.reason,
    };
    return eventPayload;
  }

  if (event === "participantsAdded") {
    const payload = genericPayload as ParticipantsAddedPayload;
    const addedByPayload = JSON.parse(unescape(payload.addedBy)) as ChatParticipantPayload;
    const participantsAddedPayload = JSON.parse(
      unescape(payload.participantsAdded)
    ) as ChatParticipantPayload[];
    const addedBy = toChatParticipant(addedByPayload);
    const chatParticipants: ChatParticipant[] = participantsAddedPayload.map((m) => {
      return toChatParticipant(m);
    });
    const eventPayload: ParticipantsAddedEvent = {
      threadId: payload.threadId,
      addedOn: new Date(payload.time),
      addedBy: addedBy,
      version: payload.version,
      participantsAdded: chatParticipants,
    };
    return eventPayload;
  }

  if (event === "participantsRemoved") {
    const payload = genericPayload as ParticipantsRemovedPayload;
    const removedByPayload = JSON.parse(unescape(payload.removedBy)) as ChatParticipantPayload;
    const participantsRemovedPayload = JSON.parse(
      unescape(payload.participantsRemoved)
    ) as ChatParticipantPayload[];
    const removedBy = toChatParticipant(removedByPayload);
    const chatParticipants: ChatParticipant[] = participantsRemovedPayload.map((m) => {
      return toChatParticipant(m);
    });
    const eventPayload: ParticipantsRemovedEvent = {
      threadId: payload.threadId,
      removedOn: new Date(payload.time),
      removedBy: removedBy,
      version: payload.version,
      participantsRemoved: chatParticipants,
    };
    return eventPayload;
  }

  return null;
};

const toChatParticipant = (payload: ChatParticipantPayload): ChatParticipant => {
  const participant: ChatParticipant = {
    id: constructIdentifierKindFromMri(payload.participantId),
    displayName: payload.displayName,
    metadata: (parseJsonString(payload.memberMetaData ?? "") as Record<string, string>) || {},
  };

  if (payload.shareHistoryTime) {
    participant.shareHistoryTime = new Date(payload.shareHistoryTime);
  }

  return participant;
};

const toThreadProperties = (payload: ChatThreadPropertiesPayload): ChatThreadProperties => {
  return {
    topic: payload.topic,
    metadata:
      (parseJsonString(payload.acsChatThreadMetadata ?? "") as Record<string, string>) || {},
  };
};

const getRetentionPolicy = (payload: ChatThreadPropertiesPayload): ChatRetentionPolicy => {
  const raw = payload.retentionPolicy;
  // No policy string ⇒ “none”
  if (!raw) {
    return { kind: "none" };
  }

  let parsed: { retentionPolicyType: string; executeAfter?: string };
  try {
    parsed = JSON.parse(raw);
  } catch {
    return { kind: "none" };
  }

  // Expected executeAfter format dd.hh:mm:ss if more than 1 day. Or hh:mm:ss if less than one day.
  if (
    parsed.retentionPolicyType === "DeleteAfterCreationTime" &&
    typeof parsed.executeAfter === "string"
  ) {
    // Handle sign, spaces
    const s = parsed.executeAfter.trim().replace(/^[+-]/, "");

    // only take the part before the dot, otherwise 0
    const daysPart = s.includes(".") ? s.split(".")[0] : "0";
    const days = parseInt(daysPart, 10);
    return {
      kind: "threadCreationDate",
      deleteThreadAfterDays: isNaN(days) ? 0 : days,
    };
  }

  return { kind: "none" };
};

export const toLogProvider = (logger: AzureLogger): LogProvider => {
  return {
    log: (...message: any) => logger.info(message),
    warn: (...message: any[]) => logger.warning(message),
    error: (...message: any[]) => logger.error(message),
    debug: (...message: any[]) => logger.verbose(message),
    info: (...message: any[]) => logger.verbose(message),
  };
};

export const toTelemetrySender = (logger: AzureLogger): ITelemetrySender => {
  return {
    logEvent: (clientEvent: TelemetryEvent) => logger.info(clientEvent),
  };
};

const constructIdentifierKindFromMri = (
  mri: string
): CommunicationUserKind | PhoneNumberKind | MicrosoftTeamsUserKind | UnknownIdentifierKind => {
  if (mri.startsWith(publicTeamsUserPrefix)) {
    return {
      kind: "microsoftTeamsUser",
      rawId: mri,
      microsoftTeamsUserId: mri.substring(publicTeamsUserPrefix.length),
      isAnonymous: false,
      cloud: "public",
    };
  } else if (mri.startsWith(dodTeamsUserPrefix)) {
    return {
      kind: "microsoftTeamsUser",
      rawId: mri,
      microsoftTeamsUserId: mri.substring(dodTeamsUserPrefix.length),
      isAnonymous: false,
      cloud: "dod",
    };
  } else if (mri.startsWith(gcchTeamsUserPrefix)) {
    return {
      kind: "microsoftTeamsUser",
      rawId: mri,
      microsoftTeamsUserId: mri.substring(gcchTeamsUserPrefix.length),
      isAnonymous: false,
      cloud: "gcch",
    };
  } else if (mri.startsWith(teamsVisitorUserPrefix)) {
    return {
      kind: "microsoftTeamsUser",
      rawId: mri,
      microsoftTeamsUserId: mri.substring(teamsVisitorUserPrefix.length),
      isAnonymous: true,
    };
  } else if (mri.startsWith(phoneNumberPrefix)) {
    return {
      kind: "phoneNumber",
      rawId: mri,
      phoneNumber: mri.substring(phoneNumberPrefix.length),
    };
  } else if (
    mri.startsWith(acsUserPrefix) ||
    mri.startsWith(acsGcchUserPrefix) ||
    mri.startsWith(acsDodUserPrefix) ||
    mri.startsWith(spoolUserPrefix)
  ) {
    return { kind: "communicationUser", communicationUserId: mri };
  } else {
    return { kind: "unknown", id: mri };
  }
};

const parseJsonString = (str: string): any => {
  if (
    str === undefined ||
    str === null ||
    str === "" ||
    str === "null" ||
    str === "{}" ||
    str === "[]"
  ) {
    return undefined;
  }
  return JSON.parse(str);
};

const createMediaUrlString = (
  urlString: string,
  resourceEndpoint: string,
  gatewayApiVersion: string
): string => {
  let url: URL | undefined;
  try {
    url = new URL(urlString);

    if (url.protocol === "http:" || url.protocol === "https:") {
      // If its already a full url, substitute the origin
      url = new URL(url.pathname, resourceEndpoint);
    }
  } catch (_) {
    // urlString is a likely a relative URL, so create a new one with the resourceEndpoint as base
    try {
      url = new URL(urlString, resourceEndpoint);
    } catch (_) {
      // If we get here, then the urlString passed in is likely incorrect, so just pass it along
      // As there's nothing we can do at this point.
      return urlString;
    }
  }

  // Append api-version query and return string
  url.searchParams.set("api-version", gatewayApiVersion);
  return url.toString();
};

const isValidURL = (str: string): boolean => {
  let url;
  try {
    url = new URL(str);
  } catch (_) {
    return false;
  }
  return url.protocol === "http:" || url.protocol === "https:";
};

const transformEndpoint = (
  attachments: ChatAttachment[],
  resourceEndpoint: string,
  gatewayApiVersion: string
): ChatAttachment[] => {
  if (
    resourceEndpoint === undefined ||
    resourceEndpoint === null ||
    resourceEndpoint === "" ||
    !isValidURL(resourceEndpoint)
  ) {
    return attachments;
  }
  attachments
    .filter((e) => e.attachmentType.toLowerCase() === "image".toLowerCase())
    .map((attachment) => {
      if (attachment.previewUrl) {
        attachment.previewUrl = createMediaUrlString(
          attachment.previewUrl,
          resourceEndpoint,
          gatewayApiVersion
        );
      }
      if (attachment.url) {
        attachment.url = createMediaUrlString(attachment.url, resourceEndpoint, gatewayApiVersion);
      }
    });
  return attachments;
};

export const base64decode = (encodedString: string): string =>
  !isNodeLike ? atob(encodedString) : Buffer.from(encodedString, "base64").toString();

const parseJWT = (token: string): any => {
  let [, payload] = token?.split(".");
  if (payload === undefined) {
    throw new Error("Invalid token");
  }
  payload = payload.replace(/-/g, "+").replace(/_/g, "/");
  return JSON.parse(decodeURIComponent(escape(base64decode(payload))));
};

export const parseTokenCredential = async (
  credential: CommunicationTokenCredential
): Promise<ParsedTokenCredential> => {
  const accessToken = await credential.getToken();
  const jwtToken = accessToken?.token;
  const parsedJwtToken = parseJWT(jwtToken);

  const identityMri = parsedJwtToken.skypeid;
  const acsResourceId = parsedJwtToken.resourceId;
  const cloudType = getCloudTypeFromSkypeId(identityMri);
  const resourceLocation = parsedJwtToken.resourceLocation || "";

  return { jwtToken, acsResourceId, identityMri, cloudType, resourceLocation };
};

export type ParsedTokenCredential = {
  // The original token
  jwtToken: string;
  // The ACS resource Id
  acsResourceId: string | undefined;
  // The MRI without the '8:'
  identityMri: string;
  // Public, Dod, GccHigh, Dod, AirGap08, or AirGap09
  cloudType: CloudType;
  // Resource location
  resourceLocation: string;
};

/**
 * Generated Universally Unique Identifier
 *
 * @returns RFC4122 v4 UUID.
 * @internal
 */
export function generateUuid(): string {
  return uuidv4();
}

export const isEudbLocation = (location: string): boolean =>
  !!location && !!EudbCountries.find((euLocation) => euLocation === location);

function getCloudTypeFromSkypeId(skypeId: string): CloudType {
  const cloudPrefix = skypeId.substring(0, skypeId.indexOf(":"));

  switch (cloudPrefix) {
    case CloudPrefix.OrgId:
    case CloudPrefix.Acs:
    case CloudPrefix.Spool: {
      return CloudType.Public;
    }

    case CloudPrefix.GccHigh:
    case CloudPrefix.GccHighAcs: {
      return CloudType.GccHigh;
    }

    case CloudPrefix.Dod:
    case CloudPrefix.DodAcs: {
      return CloudType.Dod;
    }

    default: {
      return CloudType.Public;
    }
  }
}
