/*
This file is part of the Notesnook project (https://notesnook.com/)

Copyright (C) 2023 Streetwriters (Private) Limited

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
*/

import { logger } from "../../logger.js";
import { isHTMLEqual } from "../../utils/html-diff.js";
import Database from "../index.js";
import {
  Attachment,
  ContentItem,
  Item,
  MaybeDeletedItem,
  Note,
  isDeleted
} from "../../types.js";
import { ParsedInboxItem, SyncInboxItem } from "./types.js";

const THRESHOLD = process.env.NODE_ENV === "test" ? 2 * 1000 : 60 * 1000;
class Merger {
  logger = logger.scope("Merger");
  constructor(private readonly db: Database) {}

  // isSyncCollection(type: string): type is keyof typeof SYNC_COLLECTIONS_MAP {
  //   return type in SYNC_COLLECTIONS_MAP;
  // }

  mergeItem(
    remoteItem: MaybeDeletedItem<Item>,
    localItem: MaybeDeletedItem<Item> | undefined
  ) {
    if (!localItem || remoteItem.dateModified > localItem.dateModified) {
      return remoteItem;
    }

    if (
      !remoteItem.deleted &&
      remoteItem.type === "note" &&
      !localItem.deleted &&
      localItem.type === "trash" &&
      localItem.itemType === "note" &&
      localItem.deletedBy === "expired"
    ) {
      if (
        remoteItem.expiryDate.dateModified > localItem.expiryDate.dateModified
      ) {
        localItem.expiryDate = remoteItem.expiryDate;
        (localItem as unknown as Note).type = "note";
        (localItem as unknown as Note).deletedBy = null;
        (localItem as unknown as Note).dateDeleted = null;
        (localItem as unknown as Note).itemType = null;

        return localItem;
      }
    }
  }

  mergeContent(
    remoteItem: MaybeDeletedItem<Item>,
    localItem: MaybeDeletedItem<Item> | undefined
  ) {
    if (localItem && "localOnly" in localItem && localItem.localOnly) return;

    if (
      !localItem ||
      isDeleted(localItem) ||
      isDeleted(remoteItem) ||
      remoteItem.type !== "tiptap" ||
      localItem.type !== "tiptap" ||
      !localItem.data ||
      !remoteItem.data
    ) {
      return this.mergeItem(remoteItem, localItem);
    } else {
      // it's possible that the local item already has a conflict so
      // we can just replace the conflicted content
      const conflicted = localItem.conflicted
        ? "conflict"
        : isContentConflicted(localItem, remoteItem, THRESHOLD);

      if (conflicted === "merge") return remoteItem;
      else if (!conflicted) return;

      // otherwise we trigger the conflicts
      this.logger.info("conflict marked", { id: localItem.noteId });
      localItem.conflicted = remoteItem;
      return localItem;
    }
  }

  async mergeAttachment(
    remoteItem: MaybeDeletedItem<Attachment>,
    localItem: MaybeDeletedItem<Attachment> | undefined
  ) {
    if (
      !localItem ||
      isDeleted(localItem) ||
      isDeleted(remoteItem) ||
      !localItem.dateUploaded ||
      !remoteItem.dateUploaded ||
      localItem.dateUploaded === remoteItem.dateUploaded
    ) {
      return this.mergeItem(remoteItem, localItem);
    }

    if (localItem.dateUploaded > remoteItem.dateUploaded) return;

    logger.debug("Removing local attachment file due to conflict", {
      hash: localItem.hash
    });
    const isRemoved = await this.db.fs().deleteFile(localItem.hash, true);
    if (!isRemoved)
      throw new Error(
        "Conflict could not be resolved in one of the attachments."
      );
    return remoteItem;
  }
}
export default Merger;

export function isContentConflicted(
  localItem: ContentItem,
  remoteItem: ContentItem,
  conflictThreshold: number
) {
  const isResolved =
    localItem.dateResolved &&
    remoteItem.dateModified &&
    localItem.dateResolved === remoteItem.dateModified;
  const isEdited =
    // the local item is edited if it wasn't synced yet.
    !localItem.synced;
  if (isEdited && !isResolved) {
    // If time difference between local item's edits & remote item's edits
    // is less than threshold, we shouldn't trigger a merge conflict; instead
    // we will keep the most recently changed item.
    const timeDiff =
      Math.max(remoteItem.dateEdited, localItem.dateEdited) -
      Math.min(remoteItem.dateEdited, localItem.dateEdited);

    if (
      timeDiff < conflictThreshold ||
      isHTMLEqual(localItem.data, remoteItem.data)
    ) {
      if (remoteItem.dateModified > localItem.dateModified) {
        return "merge";
      }
      return;
    }

    return "conflict";
  } else if (!isResolved) {
    return "merge";
  }
}

export async function handleInboxItems(
  inboxItems: SyncInboxItem[],
  db: Database
) {
  const inboxKeys = await db.user.getInboxKeys();
  if (!inboxKeys) {
    logger.error("No inbox keys found, cannot process inbox items.");
    return;
  }

  for (const item of inboxItems) {
    try {
      if (await db.notes.exists(item.id)) {
        logger.info("Inbox item already exists, skipping.", {
          inboxItemId: item.id
        });
        continue;
      }

      const decryptedKey = await db.storage().decryptAsymmetric(inboxKeys, {
        alg: item.key.alg,
        cipher: item.key.cipher,
        format: "base64",
        length: item.key.length
      });
      const decryptedItem = await db.storage().decrypt(
        { key: decryptedKey },
        {
          alg: item.alg,
          iv: item.iv,
          cipher: item.cipher,
          format: "base64",
          length: item.length,
          salt: item.salt
        }
      );
      const parsed = JSON.parse(decryptedItem) as ParsedInboxItem;
      if (parsed.type !== "note") {
        continue;
      }
      if (parsed.version !== 1) {
        continue;
      }

      await db.notes.add({
        id: item.id,
        title: parsed.title,
        favorite: parsed.favorite,
        pinned: parsed.pinned,
        readonly: parsed.readonly,
        content: {
          data: parsed?.content?.data ?? "",
          type: "tiptap"
        }
      });
      if (parsed.archived !== undefined) {
        await db.notes.archive(parsed.archived, item.id);
      }
      for (const notebookId of parsed.notebookIds || []) {
        if (!(await db.notebooks.exists(notebookId))) continue;
        await db.notes.addToNotebook(notebookId, item.id);
      }
      for (const tagId of parsed.tagIds || []) {
        if (!(await db.tags.exists(tagId))) continue;
        await db.relations.add(
          { type: "tag", id: tagId },
          { type: "note", id: item.id }
        );
      }
    } catch (e) {
      logger.error(e, "Failed to process inbox item.", {
        inboxItem: item
      });
      continue;
    }
  }
}
