import {
  EncryptedAttachmentInfo,
  decryptAttachment,
  encryptAttachment,
} from 'browser-encrypt-attachment';
import {
  EventTimeline,
  MatrixClient,
  MatrixError,
  MatrixEvent,
  Room,
  RoomMember,
  UploadProgress,
  UploadResponse,
} from 'matrix-js-sdk';
import type { AccountDataEvents } from 'matrix-js-sdk';
import to from 'await-to-js';
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { getStateEvent } from './room';
import { Membership, StateEvent } from '../../types/matrix/room';

const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/;

export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName);

const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@$+#])([^\s:]+):(\S+)$/);

const validMxId = (id: string): boolean => !!matchMxId(id);

export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3];

export const getMxIdLocalPart = (userId: string): string | undefined => matchMxId(userId)?.[2];

export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@');

// Anchored Matrix server name: a fully-qualified dotted domain with an
// optional port. Deliberately stricter than `isServerName`/`DOMAIN_REGEX`,
// which is an UNANCHORED substring test — that would let junk-wrapped
// strings like `matrix.org#frag`, `(matrix.org)` or a trailing-dot
// `matrix.org.` pass and produce a malformed MXID. IPs / single-label
// hosts aren't accepted here on purpose — those rare cases go through the
// explicit add-by-address form, not the forgiving inline parse.
const ADDRESS_SERVER_REGEX = /^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?::\d{1,5})?$/;

// Loosely parse a user-typed address into a canonical `@localpart:server`
// MXID, or `null` when the input isn't a deliberate full address. Two
// forms are accepted: the strict `@name:server` (also covers a paste),
// and the email-like `name@server`. The bare `name:server` form is
// intentionally NOT accepted — a single `word:word.word` token is
// indistinguishable from an everyday note or filename (`re:meeting.notes`)
// and would otherwise fire a spurious federated profile lookup. The server
// half must FULLY match `ADDRESS_SERVER_REGEX`. A bare localpart with no
// server returns `null` — reaching someone by id alone is the explicit
// add-by-address form's job (it supplies the default homeserver). Used by
// the search panel to surface a federated profile preview the instant a
// full address is typed.
export const parseUserAddress = (raw: string): string | null => {
  const input = raw.trim();
  if (!input) return null;

  // Strict MXID first (also covers a pasted `@name:server`).
  if (isUserId(input)) return input;

  // `name@server` (email-like). Split on the LAST `@` so a stray leading
  // `@` is tolerated and stripped as part of the localpart.
  const at = input.lastIndexOf('@');
  if (at > 0) {
    const localpart = input.slice(0, at).replace(/^@/, '');
    const server = input.slice(at + 1);
    if (localpart && !/[@:\s]/.test(localpart) && ADDRESS_SERVER_REGEX.test(server)) {
      return `@${localpart}:${server}`;
    }
  }

  return null;
};

export const isRoomId = (id: string): boolean => id.startsWith('!');

export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');

export const getCanonicalAliasRoomId = (mx: MatrixClient, alias: string): string | undefined =>
  mx
    .getRooms()
    ?.find(
      (room) =>
        room.getCanonicalAlias() === alias &&
        getStateEvent(room, StateEvent.RoomTombstone) === undefined
    )?.roomId;

export const getCanonicalAliasOrRoomId = (mx: MatrixClient, roomId: string): string => {
  const room = mx.getRoom(roomId);
  if (!room) return roomId;
  if (getStateEvent(room, StateEvent.RoomTombstone) !== undefined) return roomId;
  const alias = room.getCanonicalAlias();
  if (alias && getCanonicalAliasRoomId(mx, alias) === roomId) {
    return alias;
  }
  return roomId;
};

export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => {
  const info: IImageInfo = {};
  info.w = img.width;
  info.h = img.height;
  info.mimetype = fileOrBlob.type;
  info.size = fileOrBlob.size;
  return info;
};

export const getVideoInfo = (video: HTMLVideoElement, fileOrBlob: File | Blob): IVideoInfo => {
  const info: IVideoInfo = {};
  info.duration = Number.isNaN(video.duration) ? undefined : Math.floor(video.duration * 1000);
  info.w = video.videoWidth;
  info.h = video.videoHeight;
  info.mimetype = fileOrBlob.type;
  info.size = fileOrBlob.size;
  return info;
};

export const getThumbnailContent = (thumbnailInfo: {
  thumbnail: File | Blob;
  encInfo: EncryptedAttachmentInfo | undefined;
  mxc: string;
  width: number;
  height: number;
}): IThumbnailContent => {
  const { thumbnail, encInfo, mxc, width, height } = thumbnailInfo;

  const content: IThumbnailContent = {
    thumbnail_info: {
      mimetype: thumbnail.type,
      size: thumbnail.size,
      w: width,
      h: height,
    },
  };
  if (encInfo) {
    content.thumbnail_file = {
      ...encInfo,
      url: mxc,
    };
  } else {
    content.thumbnail_url = mxc;
  }
  return content;
};

export const encryptFile = async (
  file: File | Blob
): Promise<{
  encInfo: EncryptedAttachmentInfo;
  file: File;
  originalFile: File | Blob;
}> => {
  const dataBuffer = await file.arrayBuffer();
  const encryptedAttachment = await encryptAttachment(dataBuffer);
  const encFile = new File([encryptedAttachment.data], file.name, {
    type: file.type,
  });
  return {
    encInfo: encryptedAttachment.info,
    file: encFile,
    originalFile: file,
  };
};

export const decryptFile = async (
  dataBuffer: ArrayBuffer,
  type: string,
  encInfo: EncryptedAttachmentInfo
): Promise<Blob> => {
  const dataArray = await decryptAttachment(dataBuffer, encInfo);
  const blob = new Blob([dataArray], { type });
  return blob;
};

export type TUploadContent = File | Blob;

export type ContentUploadOptions = {
  name?: string;
  fileType?: string;
  hideFilename?: boolean;
  onPromise?: (promise: Promise<UploadResponse>) => void;
  onProgress?: (progress: UploadProgress) => void;
  onSuccess: (mxc: string) => void;
  onError: (error: MatrixError) => void;
};

export const uploadContent = async (
  mx: MatrixClient,
  file: TUploadContent,
  options: ContentUploadOptions
) => {
  const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options;

  const uploadPromise = mx.uploadContent(file, {
    name,
    type: fileType,
    includeFilename: !hideFilename,
    progressHandler: onProgress,
  });
  onPromise?.(uploadPromise);
  try {
    const data = await uploadPromise;
    const mxc = data.content_uri;
    if (mxc) onSuccess(mxc);
    else onError(new MatrixError(data));
  } catch (e: unknown) {
    const err = e as { message?: unknown; name?: unknown };
    const error = typeof err?.message === 'string' ? err.message : undefined;
    const errcode = typeof err?.name === 'string' ? err.name : undefined;
    onError(new MatrixError({ error, errcode }));
  }
};

export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs();

export const factoryEventSentBy = (senderId: string) => (ev: MatrixEvent) =>
  ev.getSender() === senderId;

export const eventWithShortcode = (ev: MatrixEvent) =>
  typeof ev.getContent().shortcode === 'string';

// Canonical DM lookup per Matrix spec: m.direct account data is the source
// of truth, not member-count heuristics and not encryption state. Mirrors
// Element's findDMForUser (matrix-react-sdk/src/utils/dm/findDMForUser.ts)
// including the cross-user fallback scan for stale peer state (PR #10127).
export const getDMRoomFor = (mx: MatrixClient, userId: string): Room | undefined => {
  const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as keyof AccountDataEvents);
  const mDirect = (mDirectsEvent?.getContent() ?? {}) as Record<string, string[]>;

  const myUserId = mx.getUserId();
  const pickSuitable = (roomIds: string[]): Room | undefined =>
    roomIds
      .map((rid) => mx.getRoom(rid))
      .filter((room): room is Room => {
        if (!room) return false;
        if (room.getMyMembership() !== Membership.Join) return false;
        if (getStateEvent(room, StateEvent.RoomTombstone) !== undefined) return false;
        // Strict 1-on-1: the set of active (joined or invited) members must be
        // exactly {me, userId}. Just checking getJoinedMembers().length <= 2
        // plus getMember(userId) !== undefined is unsafe — the cross-user
        // fallback below scans rooms registered under OTHER users, so a DM
        // with Alice where Bob is invited-but-not-joined would match a
        // getDMRoomFor(Bob) call and silently route messages to Alice's chat.
        const active = room
          .getMembers()
          .filter((m) => m.membership === Membership.Join || m.membership === Membership.Invite);
        if (active.length !== 2) return false;
        const peer = active.find((m) => m.userId !== myUserId);
        return !!peer && peer.userId === userId;
      })
      .sort((a, b) => b.getLastActiveTimestamp() - a.getLastActiveTimestamp())[0];

  const allDirectRoomIds = Object.keys(mDirect).reduce<string[]>(
    (acc, k) => acc.concat(mDirect[k] ?? []),
    []
  );
  return pickSuitable(mDirect[userId] ?? []) ?? pickSuitable(allDirectRoomIds);
};

export const guessDmRoomUserId = (room: Room, myUserId: string): string => {
  const getOldestMember = (members: RoomMember[]): RoomMember | undefined => {
    let oldestMemberTs: number | undefined;
    let oldestMember: RoomMember | undefined;

    const pickOldestMember = (member: RoomMember) => {
      if (member.userId === myUserId) return;

      if (
        oldestMemberTs === undefined ||
        (member.events.member && member.events.member.getTs() < oldestMemberTs)
      ) {
        oldestMember = member;
        oldestMemberTs = member.events.member?.getTs();
      }
    };

    members.forEach(pickOldestMember);

    return oldestMember;
  };

  // Pick the joined user who's been here longest (and isn't us),
  const member = getOldestMember(room.getJoinedMembers());
  if (member) return member.userId;

  // if there are no joined members other than us, use the oldest member
  const member1 = getOldestMember(
    room.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getMembers() ?? []
  );
  return member1?.userId ?? myUserId;
};

const mDirectQueues = new WeakMap<MatrixClient, Promise<void>>();
const enqueueMDirectWrite = (mx: MatrixClient, fn: () => Promise<void>): Promise<void> => {
  const prev = mDirectQueues.get(mx) ?? Promise.resolve();
  const next = prev.then(fn, fn);
  mDirectQueues.set(mx, next);
  return next;
};

export const addRoomIdToMDirect = (
  mx: MatrixClient,
  roomId: string,
  userId: string
): Promise<void> =>
  enqueueMDirectWrite(mx, async () => {
    const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as keyof AccountDataEvents);
    let userIdToRoomIds: Record<string, string[]> = {};

    if (typeof mDirectsEvent !== 'undefined')
      userIdToRoomIds = structuredClone(mDirectsEvent.getContent());

    Object.keys(userIdToRoomIds).forEach((targetUserId) => {
      const roomIds = userIdToRoomIds[targetUserId];

      if (targetUserId !== userId) {
        const indexOfRoomId = roomIds.indexOf(roomId);
        if (indexOfRoomId > -1) {
          roomIds.splice(indexOfRoomId, 1);
        }
      }
    });

    const roomIds = userIdToRoomIds[userId] || [];
    if (roomIds.indexOf(roomId) === -1) {
      roomIds.push(roomId);
    }
    userIdToRoomIds[userId] = roomIds;

    await mx.setAccountData(AccountDataEvent.Direct as keyof AccountDataEvents, userIdToRoomIds);
  });

export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise<void> =>
  enqueueMDirectWrite(mx, async () => {
    const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as keyof AccountDataEvents);
    let userIdToRoomIds: Record<string, string[]> = {};

    if (typeof mDirectsEvent !== 'undefined')
      userIdToRoomIds = structuredClone(mDirectsEvent.getContent());

    Object.keys(userIdToRoomIds).forEach((targetUserId) => {
      const roomIds = userIdToRoomIds[targetUserId];
      const indexOfRoomId = roomIds.indexOf(roomId);
      if (indexOfRoomId > -1) {
        roomIds.splice(indexOfRoomId, 1);
      }
    });

    await mx.setAccountData(AccountDataEvent.Direct as keyof AccountDataEvents, userIdToRoomIds);
  });

export const mxcUrlToHttp = (
  mx: MatrixClient,
  mxcUrl: string,
  useAuthentication?: boolean,
  width?: number,
  height?: number,
  resizeMethod?: string,
  allowDirectLinks?: boolean,
  allowRedirects?: boolean
): string | null =>
  mx.mxcUrlToHttp(
    mxcUrl,
    width,
    height,
    resizeMethod,
    allowDirectLinks,
    allowRedirects,
    useAuthentication
  );

export const downloadMedia = async (src: string): Promise<Blob> => {
  // this request is authenticated by service worker
  const res = await fetch(src, { method: 'GET' });
  const blob = await res.blob();
  return blob;
};

export const downloadEncryptedMedia = async (
  src: string,
  decryptContent: (buf: ArrayBuffer) => Promise<Blob>
): Promise<Blob> => {
  const encryptedContent = await downloadMedia(src);
  const decryptedContent = await decryptContent(await encryptedContent.arrayBuffer());

  return decryptedContent;
};

export const rateLimitedActions = async <T, R = void>(
  data: T[],
  callback: (item: T, index: number) => Promise<R>,
  maxRetryCount?: number
) => {
  let retryCount = 0;

  let actionInterval = 0;

  const sleepForMs = (ms: number) =>
    new Promise((resolve) => {
      setTimeout(resolve, ms);
    });

  const performAction = async (dataItem: T, index: number) => {
    const [err] = await to<R, MatrixError>(callback(dataItem, index));

    if (err?.httpStatus === 429) {
      if (retryCount === maxRetryCount) {
        return;
      }

      const waitMS = err.getRetryAfterMs() ?? 3000;
      actionInterval = waitMS * 1.5;
      await sleepForMs(waitMS);
      retryCount += 1;

      await performAction(dataItem, index);
    }
  };

  for (let i = 0; i < data.length; i += 1) {
    const dataItem = data[i];
    retryCount = 0;
    // eslint-disable-next-line no-await-in-loop
    await performAction(dataItem, i);
    if (actionInterval > 0) {
      // eslint-disable-next-line no-await-in-loop
      await sleepForMs(actionInterval);
    }
  }
};

export const knockSupported = (version: string): boolean => {
  const unsupportedVersion = ['1', '2', '3', '4', '5', '6'];
  return !unsupportedVersion.includes(version);
};
export const restrictedSupported = (version: string): boolean => {
  const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7'];
  return !unsupportedVersion.includes(version);
};
export const knockRestrictedSupported = (version: string): boolean => {
  const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
  return !unsupportedVersion.includes(version);
};
export const creatorsSupported = (version: string): boolean => {
  const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'];
  return !unsupportedVersion.includes(version);
};
