import { compact, uniq } from "lodash";
import randomstring from "randomstring";
import type { SaveOptions } from "sequelize";
import {
  Sequelize,
  Transaction,
  Op,
  FindOptions,
  ScopeOptions,
  WhereOptions,
} from "sequelize";
import {
  ForeignKey,
  BelongsTo,
  Column,
  Default,
  PrimaryKey,
  Table,
  BeforeValidate,
  BeforeCreate,
  BeforeUpdate,
  HasMany,
  BeforeSave,
  DefaultScope,
  AfterCreate,
  Scopes,
  DataType,
  Length as SimpleLength,
  IsNumeric,
  IsDate,
} from "sequelize-typescript";
import MarkdownSerializer from "slate-md-serializer";
import isUUID from "validator/lib/isUUID";
import getTasks from "@shared/utils/getTasks";
import parseTitle from "@shared/utils/parseTitle";
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
import { DocumentValidation } from "@shared/validations";
import slugify from "@server/utils/slugify";
import Backlink from "./Backlink";
import Collection from "./Collection";
import FileOperation from "./FileOperation";
import Revision from "./Revision";
import Star from "./Star";
import Team from "./Team";
import User from "./User";
import View from "./View";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
import DocumentHelper from "./helpers/DocumentHelper";
import Length from "./validators/Length";

const serializer = new MarkdownSerializer();

export const DOCUMENT_VERSION = 2;

@DefaultScope(() => ({
  attributes: {
    exclude: ["state"],
  },
  include: [
    {
      model: User,
      as: "createdBy",
      paranoid: false,
    },
    {
      model: User,
      as: "updatedBy",
      paranoid: false,
    },
  ],
  where: {
    publishedAt: {
      [Op.ne]: null,
    },
  },
}))
@Scopes(() => ({
  withCollectionPermissions: (userId: string, paranoid = true) => {
    if (userId) {
      return {
        include: [
          {
            attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
            model: Collection.scope({
              method: ["withMembership", userId],
            }),
            as: "collection",
            paranoid,
          },
        ],
      };
    }

    return {
      include: [
        {
          attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
          model: Collection,
          as: "collection",
          paranoid,
        },
      ],
    };
  },
  withoutState: {
    attributes: {
      exclude: ["state"],
    },
  },
  withCollection: {
    include: [
      {
        model: Collection,
        as: "collection",
      },
    ],
  },
  withStateIsEmpty: {
    attributes: {
      exclude: ["state"],
      include: [
        [
          Sequelize.literal(`CASE WHEN state IS NULL THEN true ELSE false END`),
          "stateIsEmpty",
        ],
      ],
    },
  },
  withState: {
    attributes: {
      // resets to include the state column
      exclude: [],
    },
  },
  withDrafts: {
    include: [
      {
        model: User,
        as: "createdBy",
        paranoid: false,
      },
      {
        model: User,
        as: "updatedBy",
        paranoid: false,
      },
    ],
  },
  withViews: (userId: string) => {
    if (!userId) {
      return {};
    }
    return {
      include: [
        {
          model: View,
          as: "views",
          where: {
            userId,
          },
          required: false,
          separate: true,
        },
      ],
    };
  },
}))
@Table({ tableName: "documents", modelName: "document" })
@Fix
class Document extends ParanoidModel {
  @SimpleLength({
    min: 10,
    max: 10,
    msg: `urlId must be 10 characters`,
  })
  @PrimaryKey
  @Column
  urlId: string;

  @Length({
    max: DocumentValidation.maxTitleLength,
    msg: `Document title must be ${DocumentValidation.maxTitleLength} characters or less`,
  })
  @Column
  title: string;

  @Column(DataType.ARRAY(DataType.STRING))
  previousTitles: string[] = [];

  @IsNumeric
  @Column(DataType.SMALLINT)
  version: number;

  @Column
  template: boolean;

  @Column
  fullWidth: boolean;

  @SimpleLength({
    max: 255,
    msg: `editorVersion must be 255 characters or less`,
  })
  @Column
  editorVersion: string;

  @Length({
    max: 1,
    msg: `Emoji must be a single character`,
  })
  @Column
  emoji: string | null;

  @Column(DataType.TEXT)
  text: string;

  @SimpleLength({
    max: DocumentValidation.maxStateLength,
    msg: `Document collaborative state is too large, you must create a new document`,
  })
  @Column(DataType.BLOB)
  state: Uint8Array;

  @Default(false)
  @Column
  isWelcome: boolean;

  @IsNumeric
  @Default(0)
  @Column(DataType.INTEGER)
  revisionCount: number;

  @IsDate
  @Column
  archivedAt: Date | null;

  @IsDate
  @Column
  publishedAt: Date | null;

  @Column(DataType.ARRAY(DataType.UUID))
  collaboratorIds: string[] = [];

  // getters

  get url() {
    if (!this.title) {
      return `/doc/untitled-${this.urlId}`;
    }
    const slugifiedTitle = slugify(this.title);
    return `/doc/${slugifiedTitle}-${this.urlId}`;
  }

  get tasks() {
    return getTasks(this.text || "");
  }

  // hooks

  @BeforeSave
  static async updateTitleInCollectionStructure(
    model: Document,
    { transaction }: SaveOptions<Document>
  ) {
    // templates, drafts, and archived documents don't appear in the structure
    // and so never need to be updated when the title changes
    if (
      model.archivedAt ||
      model.template ||
      !model.publishedAt ||
      !model.changed("title")
    ) {
      return;
    }

    const collection = await Collection.findByPk(model.collectionId, {
      transaction,
      lock: Transaction.LOCK.UPDATE,
    });
    if (!collection) {
      return;
    }

    await collection.updateDocument(model, { transaction });
    model.collection = collection;
  }

  @AfterCreate
  static async addDocumentToCollectionStructure(model: Document) {
    if (model.archivedAt || model.template || !model.publishedAt) {
      return;
    }

    return this.sequelize!.transaction(async (transaction: Transaction) => {
      const collection = await Collection.findByPk(model.collectionId, {
        transaction,
        lock: transaction.LOCK.UPDATE,
      });
      if (!collection) {
        return;
      }

      await collection.addDocumentToStructure(model, 0, { transaction });
      model.collection = collection;
    });
  }

  @BeforeValidate
  static createUrlId(model: Document) {
    return (model.urlId = model.urlId || randomstring.generate(10));
  }

  @BeforeCreate
  static setDocumentVersion(model: Document) {
    if (model.version === undefined) {
      model.version = DOCUMENT_VERSION;
    }

    return this.processUpdate(model);
  }

  @BeforeUpdate
  static processUpdate(model: Document) {
    const { emoji } = parseTitle(model.title);
    // emoji in the title is split out for easier display
    model.emoji = emoji || null;

    // ensure documents have a title
    model.title = model.title || "";

    if (model.previous("title") && model.previous("title") !== model.title) {
      if (!model.previousTitles) {
        model.previousTitles = [];
      }

      model.previousTitles = uniq(
        model.previousTitles.concat(model.previous("title"))
      );
    }

    // add the current user as a collaborator on this doc
    if (!model.collaboratorIds) {
      model.collaboratorIds = [];
    }

    model.collaboratorIds = uniq(
      model.collaboratorIds.concat(model.lastModifiedById)
    );

    // increment revision
    model.revisionCount += 1;
  }

  // associations

  @BelongsTo(() => FileOperation, "importId")
  import: FileOperation | null;

  @ForeignKey(() => FileOperation)
  @Column(DataType.UUID)
  importId: string | null;

  @BelongsTo(() => Document, "parentDocumentId")
  parentDocument: Document | null;

  @ForeignKey(() => Document)
  @Column(DataType.UUID)
  parentDocumentId: string | null;

  @BelongsTo(() => User, "lastModifiedById")
  updatedBy: User;

  @ForeignKey(() => User)
  @Column(DataType.UUID)
  lastModifiedById: string;

  @BelongsTo(() => User, "createdById")
  createdBy: User;

  @ForeignKey(() => User)
  @Column(DataType.UUID)
  createdById: string;

  @BelongsTo(() => Document, "templateId")
  document: Document;

  @ForeignKey(() => Document)
  @Column(DataType.UUID)
  templateId: string;

  @BelongsTo(() => Team, "teamId")
  team: Team;

  @ForeignKey(() => Team)
  @Column(DataType.UUID)
  teamId: string;

  @BelongsTo(() => Collection, "collectionId")
  collection: Collection | null | undefined;

  @ForeignKey(() => Collection)
  @Column(DataType.UUID)
  collectionId: string;

  @HasMany(() => Revision)
  revisions: Revision[];

  @HasMany(() => Backlink)
  backlinks: Backlink[];

  @HasMany(() => Star)
  starred: Star[];

  @HasMany(() => View)
  views: View[];

  static defaultScopeWithUser(userId: string) {
    const collectionScope: Readonly<ScopeOptions> = {
      method: ["withCollectionPermissions", userId],
    };
    const viewScope: Readonly<ScopeOptions> = {
      method: ["withViews", userId],
    };
    return this.scope(["defaultScope", collectionScope, viewScope]);
  }

  static async findByPk(
    id: string,
    options: FindOptions<Document> & {
      userId?: string;
      includeState?: boolean;
    } = {}
  ): Promise<Document | null> {
    // allow default preloading of collection membership if `userId` is passed in find options
    // almost every endpoint needs the collection membership to determine policy permissions.
    const scope = this.scope([
      ...(options.includeState ? [] : ["withoutState"]),
      "withDrafts",
      {
        method: ["withCollectionPermissions", options.userId, options.paranoid],
      },
      {
        method: ["withViews", options.userId],
      },
    ]);

    if (isUUID(id)) {
      return scope.findOne({
        where: {
          id,
        },
        ...options,
      });
    }

    const match = id.match(SLUG_URL_REGEX);
    if (match) {
      return scope.findOne({
        where: {
          urlId: match[1],
        },
        ...options,
      });
    }

    return null;
  }

  // instance methods

  migrateVersion = () => {
    let migrated = false;

    // migrate from document version 0 -> 1
    if (!this.version) {
      // removing the title from the document text attribute
      this.text = this.text.replace(/^#\s(.*)\n/, "");
      this.version = 1;
      migrated = true;
    }

    // migrate from document version 1 -> 2
    if (this.version === 1) {
      const nodes = serializer.deserialize(this.text);
      this.text = serializer.serialize(nodes, {
        version: 2,
      });
      this.version = 2;
      migrated = true;
    }

    if (migrated) {
      return this.save({
        silent: true,
        hooks: false,
      });
    }

    return undefined;
  };

  get titleWithDefault(): string {
    return this.title || "Untitled";
  }

  /**
   * Get a list of users that have collaborated on this document
   *
   * @param options FindOptions
   * @returns A promise that resolve to a list of users
   */
  collaborators = async (options?: FindOptions<User>): Promise<User[]> => {
    const users = await Promise.all(
      this.collaboratorIds.map((collaboratorId) =>
        User.findByPk(collaboratorId, options)
      )
    );

    return compact(users);
  };

  /**
   * Calculate all of the document ids that are children of this document by
   * iterating through parentDocumentId references in the most efficient way.
   *
   * @param where query options to further filter the documents
   * @param options FindOptions
   * @returns A promise that resolves to a list of document ids
   */
  getChildDocumentIds = async (
    where?: Omit<WhereOptions<Document>, "parentDocumentId">,
    options?: FindOptions<Document>
  ): Promise<string[]> => {
    const getChildDocumentIds = async (
      ...parentDocumentId: string[]
    ): Promise<string[]> => {
      const childDocuments = await (this
        .constructor as typeof Document).findAll({
        attributes: ["id"],
        where: {
          parentDocumentId,
          ...where,
        },
        ...options,
      });

      const childDocumentIds = childDocuments.map((doc) => doc.id);

      if (childDocumentIds.length > 0) {
        return [
          ...childDocumentIds,
          ...(await getChildDocumentIds(...childDocumentIds)),
        ];
      }

      return childDocumentIds;
    };

    return getChildDocumentIds(this.id);
  };

  archiveWithChildren = async (
    userId: string,
    options?: FindOptions<Document>
  ) => {
    const archivedAt = new Date();

    // Helper to archive all child documents for a document
    const archiveChildren = async (parentDocumentId: string) => {
      const childDocuments = await (this
        .constructor as typeof Document).findAll({
        where: {
          parentDocumentId,
        },
      });
      childDocuments.forEach(async (child) => {
        await archiveChildren(child.id);
        child.archivedAt = archivedAt;
        child.lastModifiedById = userId;
        await child.save(options);
      });
    };

    await archiveChildren(this.id);
    this.archivedAt = archivedAt;
    this.lastModifiedById = userId;
    return this.save(options);
  };

  publish = async (userId: string, { transaction }: SaveOptions<Document>) => {
    // If the document is already published then calling publish should act like
    // a regular save
    if (this.publishedAt) {
      return this.save({ transaction });
    }

    if (!this.template) {
      const collection = await Collection.findByPk(this.collectionId, {
        transaction,
        lock: Transaction.LOCK.UPDATE,
      });

      if (collection) {
        await collection.addDocumentToStructure(this, 0, { transaction });
        this.collection = collection;
      }
    }

    this.lastModifiedById = userId;
    this.publishedAt = new Date();
    return this.save({ transaction });
  };

  unpublish = async (userId: string) => {
    // If the document is already a draft then calling unpublish should act like
    // a regular save
    if (!this.publishedAt) {
      return this.save();
    }

    await this.sequelize.transaction(async (transaction: Transaction) => {
      const collection = await Collection.findByPk(this.collectionId, {
        transaction,
        lock: transaction.LOCK.UPDATE,
      });

      if (collection) {
        await collection.removeDocumentInStructure(this, { transaction });
        this.collection = collection;
      }
    });

    // unpublishing a document converts the ownership to yourself, so that it
    // will appear in your drafts rather than the original creators
    this.createdById = userId;
    this.lastModifiedById = userId;
    this.publishedAt = null;
    return this.save();
  };

  // Moves a document from being visible to the team within a collection
  // to the archived area, where it can be subsequently restored.
  archive = async (userId: string) => {
    await this.sequelize.transaction(async (transaction: Transaction) => {
      const collection = await Collection.findByPk(this.collectionId, {
        transaction,
        lock: transaction.LOCK.UPDATE,
      });

      if (collection) {
        await collection.removeDocumentInStructure(this, { transaction });
        this.collection = collection;
      }
    });

    await this.archiveWithChildren(userId);
    return this;
  };

  // Restore an archived document back to being visible to the team
  unarchive = async (userId: string) => {
    await this.sequelize.transaction(async (transaction: Transaction) => {
      const collection = await Collection.findByPk(this.collectionId, {
        transaction,
        lock: transaction.LOCK.UPDATE,
      });

      // check to see if the documents parent hasn't been archived also
      // If it has then restore the document to the collection root.
      if (this.parentDocumentId) {
        const parent = await (this.constructor as typeof Document).findOne({
          where: {
            id: this.parentDocumentId,
            archivedAt: {
              [Op.is]: null,
            },
          },
        });
        if (!parent) {
          this.parentDocumentId = null;
        }
      }

      if (!this.template && collection) {
        await collection.addDocumentToStructure(this, undefined, {
          transaction,
        });
        this.collection = collection;
      }
    });

    if (this.deletedAt) {
      await this.restore();
    }

    this.archivedAt = null;
    this.lastModifiedById = userId;
    await this.save();
    return this;
  };

  // Delete a document, archived or otherwise.
  delete = (userId: string) => {
    return this.sequelize.transaction(async (transaction: Transaction) => {
      if (!this.archivedAt && !this.template && this.collectionId) {
        // delete any children and remove from the document structure
        const collection = await Collection.findByPk(this.collectionId, {
          transaction,
          lock: transaction.LOCK.UPDATE,
        });
        await collection?.deleteDocument(this, { transaction });
      } else {
        await this.destroy({
          transaction,
        });
      }

      await Revision.destroy({
        where: {
          documentId: this.id,
        },
        transaction,
      });
      await this.update(
        {
          lastModifiedById: userId,
        },
        {
          transaction,
        }
      );
      return this;
    });
  };

  getTimestamp = () => {
    return Math.round(new Date(this.updatedAt).getTime() / 1000);
  };

  getSummary = () => {
    const plainText = DocumentHelper.toPlainText(this);
    const lines = compact(plainText.split("\n"));
    const notEmpty = lines.length >= 1;

    if (this.version) {
      return notEmpty ? lines[0] : "";
    }

    return notEmpty ? lines[1] : "";
  };

  toJSON = () => {
    // Warning: only use for new documents as order of children is
    // handled in the collection's documentStructure
    return {
      id: this.id,
      title: this.title,
      url: this.url,
      children: [],
    };
  };
}

export default Document;
