from naas_drivers.driver import InDriver, OutDriver
from notion_client import Client
import dataclasses
from dataclasses import dataclass, field
from dacite import from_dict
from typing import List, Optional, Union
from numbers import Number
from enum import Enum
import json
import logging
from copy import deepcopy
import pandas as pd
import threading

VERSION = "2021-08-16"

global notion_instance


def ensure_page_id(page_id):
    if "http" in page_id:
        page_id = page_id.split("/")[-1].split("-")[-1]
    return page_id


def ensure_database_id(database_id):
    if "http" in database_id:
        database_id = database_id.split("/")[-1].split("?")[0]
    return database_id


class Notion(InDriver, OutDriver):
    __client = None

    def __init__(self):
        """
        Classes / Methods to query notion
        """
        self.database = self.Databases(self)
        self.databases = self.database

        self.page = self.Pages(self)
        self.pages = self.page

        self.block = self.Blocks(self)
        self.blocks = self.block

        self.user = self.Users(self)
        self.users = self.user

        """
        Data classes representing Page/Database/Blocks.
        """
        self.Page = Page
        self.Database = Database
        self.Block = Block

        """
        Global to allow all dataclasses to access notion client and make requests
        from a dataclass directly.
        """
        global notion_instance
        notion_instance = self

    @staticmethod
    def instance():
        return notion_instance

    @property
    def client(self):
        return self.__client

    def connect(self, token: str, log_level: int = logging.WARNING):
        self.__client = Client(auth=token, log_level=log_level)
        return self

    def to_json(self, source):
        return json.dumps(source, cls=EnhancedJSONEncoder, ensure_ascii=False)

    def to_dict(self, source):
        return del_none(json.loads(self.to_json(source)))

    class __InnerBase:
        __parent = None

        def __init__(self, parent):
            self.__parent = parent

        @property
        def client(self):
            return self.__parent.client

        @property
        def parent(self):
            return self.__parent

    class Databases(__InnerBase):
        def __from_dict(self, data):
            return from_dict(data_class=Database, data=data)

        def query(self, database_id, query={}):
            database_id = ensure_database_id(database_id)
            ret = []
            has_more = True
            next_cursor = None

            while has_more:
                if has_more is True and next_cursor is not None:
                    query["start_cursor"] = next_cursor

                response = self.client.databases.query(database_id=database_id, **query)
                results = response.get("results")

                for r in results:
                    ret.append(from_dict(data_class=Page, data=r))

                if response.get("has_more") is True:
                    has_more = True
                    next_cursor = response.get("next_cursor")
                else:
                    has_more = False

            return ret

        def create(self, db):
            payload = self.parent.to_dict(db)
            return self.__from_dict(self.client.databases.create(**payload))

        def update(self, db):
            payload = self.parent.to_dict(db)
            return self.__from_dict(
                self.client.databases.update(database_id=db.id, **payload)
            )

        def get(self, database_id):
            return self.retrieve(database_id)

        def retrieve(self, database_id):
            database_id = ensure_database_id(database_id)
            raw = self.client.databases.retrieve(database_id)
            return self.__from_dict(raw)

    class Pages(__InnerBase):
        def __from_dict(self, data):
            return from_dict(data_class=Page, data=data)

        def create_from_page(self, page):
            payload = self.parent.to_dict(page)
            return self.__from_dict(self.client.pages.create(**payload))

        def create(self, database_id, title):
            database_id = ensure_database_id(database_id)
            new_page = Page.new(database_id=database_id)
            new_page.title("Name", title)
            return new_page.create()

        def retrieve(self, page_id: str):
            page_id = ensure_page_id(page_id)
            page = self.client.pages.retrieve(page_id=page_id)
            return self.__from_dict(page)

        def get(self, page_id: str):
            return self.retrieve(page_id)

        def update(self, page):  # TODO: Improve readability.
            copied = deepcopy(page)
            filtered_properties = {}
            for p in copied.properties:
                prop = copied.properties[p]
                if (
                    prop.type
                    not in [
                        "last_edited_by",
                        "last_edited_time",
                        "created_by",
                        "created_time",
                    ]
                    and getattr(prop, prop.type) is not None
                ):
                    filtered_properties[p] = prop
            copied.properties = filtered_properties
            payload = self.parent.to_dict(copied)
            return self.__from_dict(
                self.client.pages.update(page_id=copied.id, **payload)
            )

    class Blocks(__InnerBase):
        def __from_dict(self, data):
            return from_dict(data_class=Block, data=data)

        def retrieve(self, block_id: str):
            block = self.client.blocks.retrieve(block_id=block_id)
            return self.__from_dict(block)

        def get(self, block_id: str):
            return self.retrieve(block_id)

        def children(self, block_id: str):
            ret = []
            blocks = self.client.blocks.children.list(block_id=block_id).get("results")
            for r in blocks:
                ret.append(self.__from_dict(r))
            return ret

        def append(self, block_id, blocks):
            if type(blocks) != list:
                blocks = [blocks]
            payload = self.parent.to_dict(blocks)
            ret = []
            for b in self.client.blocks.children.append(
                block_id=block_id, children=payload
            ).get("results"):
                ret.append(self.__from_dict(b))
            return ret

        def update(self, block):
            payload = self.parent.to_dict(block)
            return self.__from_dict(
                self.client.blocks.update(block_id=block.id, **payload)
            )

        def delete(self, block_id):
            return self.__from_dict(
                self.client.blocks.delete(block_id=f"/{block_id}")
            )  # TODO: Remove '/' when PR is merged.

    class Users(__InnerBase):
        def __from_dict(self, data):
            return from_dict(data_class=User, data=data)

        def retrieve(self, user_id):
            return self.__from_dict(self.client.users.retrieve(user_id=user_id))

        def get(self, user_id):
            return self.retrieve(user_id)

        def list(self):
            ret = []
            for b in self.client.users.list().get("results"):
                ret.append(self.__from_dict(b))
            return ret


"""Notion Data classes"""


class __BaseDataClass:
    """
    __BaseDataClass is used to make it easy from any @dataclass to access notion client.
    It allows to update/create/delete/query objects directly from a dataclass.
    """

    def to_json(self, source=None):
        if source is None:
            source = self
        return json.dumps(source, cls=EnhancedJSONEncoder)

    @property
    def notion(self):
        return Notion.instance()


@dataclass
class FileExternal:
    url: Optional[str] = ""

    @classmethod
    def new(cls, url: str) -> "FileExternal":
        return cls(url=url)


@dataclass
class File(__BaseDataClass):
    url: Optional[str]
    expiry_time: Optional[str]
    name: Optional[str]
    external: Optional[FileExternal] = field(default_factory=FileExternal)
    type: str = "external"

    @classmethod
    def new(cls, url: str) -> "File":
        return cls(
            url=None, expiry_time=None, name=None, external=FileExternal(url=url)
        )


@dataclass
class Emoji(__BaseDataClass):
    emoji: str
    type: str = "emoji"

    @classmethod
    def new(cls, emoji: str) -> "Emoji":
        return cls(emoji=emoji)


@dataclass
class Parent(__BaseDataClass):
    type: str
    workspace: Optional[bool] = None
    page_id: Optional[str] = None
    database_id: Optional[str] = None

    @classmethod
    def new(cls, type: str, value: Union[bool, str]) -> "Parent":
        if type == "page_id":
            return cls(type=type, page_id=value)
        elif type == "database_id":
            return cls(type=type, database_id=value)
        elif type == "workspace":
            return cls(type=type, workspace=value)
        else:
            return cls(type=type)

    @classmethod
    def new_page_parent(cls, page_id):
        return cls(type="page_id", page_id=page_id)

    @classmethod
    def new_workspace_parent(cls):
        return cls(type="workspace", workspace=True)

    @classmethod
    def new_database_parent(cls, database_id):
        return cls(type="database_id", database_id=database_id)


@dataclass
class Link(__BaseDataClass):
    url: str
    type: str = "url"

    @classmethod
    def new(cls, url: str) -> "Link":
        return cls(url=url)


@dataclass
class Text(__BaseDataClass):
    content: str
    link: Optional[Link] = None

    @classmethod
    def new(cls, content: str) -> "Text":
        return cls(content=content)


@dataclass
class Annotation(__BaseDataClass):
    bold: bool = False
    italic: bool = False
    strikethrough: bool = False
    underline: bool = False
    code: bool = False
    color: str = "default"

    @classmethod
    def new(cls) -> "Annotation":
        return cls()


@dataclass
class Person(__BaseDataClass):
    email: str

    @classmethod
    def new(cls, email: str) -> "Person":
        return cls(email=email)


@dataclass
class User(__BaseDataClass):
    object: str
    id: str
    type: Optional[str]
    name: Optional[str]
    avatar_url: Optional[str]
    person: Optional[Person]
    bot: Optional[object]

    @classmethod
    def new(cls, user_id: str) -> "User":
        return notion_instance.users.get(user_id)

    def __post_init__(self):
        if self.type == "person":
            self.person = Person("")
        if self.type == "bot":
            self.bot = {}


@dataclass
class Date(__BaseDataClass):
    start: str
    end: Optional[str]

    @classmethod
    def new(cls, start: str) -> "Date":
        return cls(start=start)


@dataclass
class Equation(__BaseDataClass):
    expression: str

    @classmethod
    def new(cls, expression: str) -> "Equation":
        return from_dict(data_class=cls, expression=expression)


@dataclass
class UserMention(__BaseDataClass):
    type: str
    user: User


@dataclass
class RichText(__BaseDataClass):
    plain_text: str
    href: Optional[str] = None
    annotations: Annotation = field(default_factory=Annotation)
    type: str = "text"
    text: Optional[Text] = None
    mention: Optional[UserMention] = None
    page: Optional[object] = None
    database: Optional[object] = None
    date: Optional[Date] = None
    equation: Optional[Equation] = None

    @classmethod
    def new(cls, text: str) -> "RichText":
        return cls(type="text", plain_text=text, text=Text.new(text))

    @classmethod
    def new_text(cls, content):
        return cls(plain_text=content, text=Text(content=content))


"""
Database and Database properties

This section is containing all classes related to Properties (https://developers.notion.com/reference/database#property-object)
"""


@dataclass
class Property(__BaseDataClass):
    id: Optional[str] = None
    type: str = None  # TODO: Consider using enum to improve typing.


@dataclass
class DatabaseProperty(Property):
    name: str = None


@dataclass
class DatabasePropertyTitle(DatabaseProperty):
    title: object = field(default_factory=dict)
    type: str = "title"


@dataclass
class DatabasePropertyText(DatabaseProperty):
    rich_text: object = field(default_factory=dict)
    type: str = "rich_text"


@dataclass
class DatabasePropertyNumber_Configuration:
    format: str = "number"


@dataclass
class DatabasePropertyNumber(DatabaseProperty):
    number: DatabasePropertyNumber_Configuration = field(default_factory=dict)
    type: str = "number"


@dataclass
class DatabasePropertySelect_Configuration_Option:
    name: str
    id: str
    color: str  # TODO: enum


@dataclass
class DatabasePropertySelect_Configuration:
    options: List[DatabasePropertySelect_Configuration_Option] = field(
        default_factory=list
    )


@dataclass
class DatabasePropertySelect(DatabaseProperty):
    select: DatabasePropertySelect_Configuration = field(
        default_factory=DatabasePropertySelect_Configuration
    )
    type: str = "select"

    def get_option_by_name(self, name):
        if self.select and self.select.options:
            for option in self.select.options:
                if option.name == name:
                    return option
        return None


@dataclass
class DatabasePropertyMultiSelect(DatabaseProperty):
    multi_select: DatabasePropertySelect_Configuration = field(
        default_factory=DatabasePropertySelect_Configuration
    )  # TODO: Create own childs? Not reuse DatabasePropertySelect_Configuration class.
    type: str = "multi_select"

    def get_option_by_name(self, name):
        if self.multi_select and self.multi_select.options:
            for option in self.multi_select.options:
                if option.name == name:
                    return option
        return None


@dataclass
class DatabasePropertyDate(DatabaseProperty):
    date: object = field(default_factory=dict)
    type: str = "date"


@dataclass
class DatabasePropertyPeople(DatabaseProperty):
    people: object = field(default_factory=dict)
    type: str = "people"


@dataclass
class DatabasePropertyFiles(DatabaseProperty):
    files: object = field(default_factory=dict)
    type: str = "files"


@dataclass
class DatabasePropertyCheckbox(DatabaseProperty):
    checkbox: object = field(default_factory=dict)
    type: str = "checkbox"


@dataclass
class DatabasePropertyUrl(DatabaseProperty):
    url: object = field(default_factory=dict)
    type: str = "url"


@dataclass
class DatabasePropertyEmail(DatabaseProperty):
    email: object = field(default_factory=dict)
    type: str = "email"


@dataclass
class DatabasePropertyPhoneNumber(DatabaseProperty):
    phone_number: object = field(default_factory=dict)
    type: str = "phone_number"


@dataclass
class DatabasePropertyFormula_Configuration:
    expression: str = ""
    type: str = "expression"


@dataclass
class DatabasePropertyFormula(DatabaseProperty):
    formula: DatabasePropertyFormula_Configuration = field(
        default_factory=DatabasePropertyFormula_Configuration
    )
    type: str = "formula"


@dataclass
class DatabasePropertyRelation_Configuration:
    database_id: str
    synced_property_name: Optional[str]
    synced_property_id: Optional[str]


@dataclass
class DatabasePropertyRelation(DatabaseProperty):
    relation: DatabasePropertyRelation_Configuration = field(
        default_factory=DatabasePropertyRelation_Configuration
    )
    type: str = "relation"


@dataclass
class DatabasePropertyRollup_Configuration:
    relation_property_name: str
    relation_property_id: str
    rollup_property_name: str
    rollup_property_id: str
    function: str


@dataclass
class DatabasePropertyRollup(DatabaseProperty):
    rollup: DatabasePropertyRollup_Configuration = field(
        default_factory=DatabasePropertyRollup_Configuration
    )
    type: str = "rollup"


@dataclass
class DatabasePropertyCreatedBy(DatabaseProperty):
    created_by: object = field(default_factory=dict)
    type: str = "created_by"


@dataclass
class DatabasePropertyCreatedTime(DatabaseProperty):
    created_time: object = field(default_factory=dict)
    type: str = "created_time"


@dataclass
class DatabasePropertyLastEditedTime(DatabaseProperty):
    last_edited_time: object = field(default_factory=dict)
    type: str = "last_edited_time"


@dataclass
class DatabasePropertyLastEditedBy(DatabaseProperty):
    last_edited_by: object = field(default_factory=dict)
    type: str = "last_edited_by"


class DatabasePropertyFactory:
    """
    This class is a helper to create the proper DatabaseProperty type automaticaly.
    """

    __rel_map = {
        "title": DatabasePropertyTitle,
        "rich_text": DatabasePropertyText,
        "number": DatabasePropertyNumber,
        "select": DatabasePropertySelect,
        "multi_select": DatabasePropertyMultiSelect,
        "date": DatabasePropertyDate,
        "people": DatabasePropertyPeople,
        "files": DatabasePropertyFiles,
        "checkbox": DatabasePropertyCheckbox,
        "url": DatabasePropertyUrl,
        "email": DatabasePropertyEmail,
        "phone_number": DatabasePropertyPhoneNumber,
        "formula": DatabasePropertyFormula,
        "relation": DatabasePropertyRelation,
        "rollup": DatabasePropertyRollup,
        "created_by": DatabasePropertyCreatedBy,
        "created_time": DatabasePropertyCreatedTime,
        "last_edited_time": DatabasePropertyLastEditedTime,
        "last_edited_by": DatabasePropertyLastEditedBy,
    }

    @staticmethod
    def new(data):
        data_type = data.get("type")
        if data_type and data_type in DatabasePropertyFactory.__rel_map:
            return from_dict(
                data_class=DatabasePropertyFactory.__rel_map[data_type], data=data
            )
        else:
            raise Exception(f'DatabaseProperty "{data_type}" not implemented yet.')


@dataclass
class Database(__BaseDataClass):
    title: List[RichText]
    properties: object
    parent: Parent
    # icon: Optional[Emoji] = None  # TODO: Handle File
    cover: Optional[File] = None
    created_time: str = None
    last_edited_time: str = None
    url: str = None
    object: str = "database"
    id: str = None

    """
    __post_init__ is called right after the object is created.

    We use it here to keep self.properties as a Dictionnary but we
    want to convert values as Data Classes instances.
    """

    def __post_init__(self):
        for k in self.properties:
            self.properties[k] = DatabasePropertyFactory.new(self.properties[k])

    def __repr__(self):
        try:
            display(self.df())  # noqa: F821
            return ""
        except:  # noqa: E722
            return str(self)

    def schema(self):
        copied = deepcopy(self.properties)
        d = []
        for i in copied:
            p = copied[i]
            d.append({"Name": i, "Type": p.type})
        return pd.DataFrame(self.notion.to_dict(d))

    @classmethod
    def new(cls, title: str, page_id: str = None):
        parent = None
        if page_id:
            parent = Parent.new_page_parent(page_id=page_id)
        else:
            parent = Parent.new_workspace_parent()

        new_db = cls(title=[RichText.new(title)], parent=parent, properties={})
        new_db.add_property("Name", "title")
        return new_db

    @classmethod
    def from_dict(cls, data):
        return from_dict(data_class=cls, data=data)

    # def set_emoji_icon(
    #    self, data
    # ):  # TODO: Fix, seems like there is an issue with notion-client / httpx maybe.
    #    self.icon = Emoji(data)

    def duplicate(self):
        new_db = self.create()
        pages = self.query()
        for page in pages:
            page.parent.database_id = new_db.id
            page.duplicate()

        return new_db

    def add_property(self, col_name, type_name):
        data = {"type": type_name, "name": col_name, "id": ""}
        self.properties[col_name] = DatabasePropertyFactory.new(data)

    def df(self):
        pages = self.query({"page_size": 10})
        frames = []

        for page in pages:
            frame = {}
            for prop in page.properties:
                frame[prop] = str(page.properties[prop])
            frames.append(frame)

        return pd.DataFrame(frames)

    def query(self, query={}):
        return self.notion.databases.query(self.id, query)

    def update(self):
        return self.notion.databases.update(self)

    def create(self):
        return self.notion.databases.create(self)


"""
Page and Page properties
"""


@dataclass
class PageProperty(Property):
    pass


@dataclass
class PagePropertyTitle(PageProperty):
    title: Optional[List[RichText]] = field(default_factory=list)
    type: str = "title"

    @classmethod
    def new(cls, content: str) -> "PagePropertyTitle":
        return cls(title=[RichText.new(content)])

    def set_text(self, content: str):
        self.title = [RichText.new_text(content)]

    def __str__(self):
        return " ".join([v.plain_text for v in self.title])


@dataclass
class PagePropertyText(PageProperty):
    rich_text: Optional[List[RichText]] = field(default_factory=list)
    type: str = "rich_text"

    @classmethod
    def new(cls, content: str) -> "PagePropertyText":
        return cls(rich_text=[RichText.new(content)])

    def __str__(self):
        return " ".join([v.plain_text for v in self.rich_text])


@dataclass
class PagePropertyNumber(PageProperty):
    number: Optional[Number] = None
    type: str = "number"

    @classmethod
    def new(cls, number: Number) -> "PagePropertyNumber":
        return cls(number=number)

    def __str__(self):
        return str(self.number)


@dataclass
class PagePropertySelect_Value:
    name: str
    id: str = None
    color: str = "default"

    @classmethod
    def new(cls, name: str, color: str = "default") -> "PagePropertySelect_Value":
        return cls(name=name, color=color)


@dataclass
class PagePropertySelect(PageProperty):
    select: Optional[PagePropertySelect_Value] = field(
        default_factory=PagePropertySelect_Value
    )
    type: str = "select"

    @classmethod
    def new(cls, select: str, color: str = "default") -> "PagePropertySelect":
        return cls(select=PagePropertySelect_Value.new(name=select, color=color))

    def __str__(self):
        return str(self.select.name) if self.select else ""


@dataclass
class PagePropertyMultiSelect(PageProperty):
    multi_select: Optional[List[PagePropertySelect_Value]] = field(
        default_factory=list
    )  # TODO: Create own Value type.
    type: str = "multi_select"

    @classmethod
    def new(
        cls, values: List[str], color: str = "default"
    ) -> "PagePropertyMultiSelect":
        return cls(
            multi_select=[PagePropertySelect_Value.new(v, color) for v in values]
        )

    def __str__(self):
        return ", ".join([v.name for v in self.multi_select])


@dataclass
class PagePropertyDate_Value:
    start: str = None
    end: Optional[str] = None

    @classmethod
    def new(cls, start=str) -> "PagePropertyDate_Value":
        return cls(start=start)


@dataclass
class PagePropertyDate(PageProperty):
    date: Optional[PagePropertyDate_Value] = field(
        default_factory=PagePropertyDate_Value
    )
    type: str = "date"

    @classmethod
    def new(cls, date: str) -> "PagePropertyDate":
        return cls(date=PagePropertyDate_Value.new(date))

    def __str__(self):
        return str(self.date.start) if self.date else ""


@dataclass
class PagePropertyFormula(PageProperty):
    string: Optional[str] = None
    number: Optional[Number] = None
    boolean: Optional[bool] = None
    date: Optional[PagePropertyDate_Value] = field(
        default_factory=PagePropertyDate_Value
    )
    type: str = "formula"

    # TODO: classmethod new

    # TODO: classmethod new


@dataclass
class PagePropertyRelation(PageProperty):
    relation: List[object] = field(default_factory=list)
    type: str = "relation"

    # TODO: classmethod new


@dataclass
class PagePropertyRollup(PageProperty):
    number: Optional[Number] = None
    date: Optional[PagePropertyDate_Value] = field(
        default_factory=PagePropertyDate_Value
    )
    array: Optional[List[object]] = field(default_factory=list)
    type: str = "rollup"

    # TODO: classmethod new


@dataclass
class PagePropertyPeople(PageProperty):
    people: Optional[List[User]] = field(default_factory=list)
    type: str = "people"

    @classmethod
    def new(cls, people: List[str]) -> "PagePropertyPeople":
        return cls(people=[User.new(v) for v in people])

    def __str__(self):
        return " ".join([v.name for v in self.people])


@dataclass
class PagePropertyFiles(PageProperty):
    files: Optional[List[File]] = field(default_factory=list)
    type: str = "files"

    @classmethod
    def new(cls, files: List[str]) -> "PagePropertyFiles":
        return cls(files=[File.new(v) for v in files])

    def __str__(self):
        return ", ".join([v.name for v in self.files])


@dataclass
class PagePropertyCheckbox(PageProperty):
    checkbox: bool = None
    type: str = "checkbox"

    @classmethod
    def new(cls, checkbox: bool) -> "PagePropertyCheckbox":
        return cls(checkbox=checkbox)

    def __str__(self):
        return str(self.checkbox)


@dataclass
class PagePropertyUrl(PageProperty):
    url: Optional[str] = None
    type: str = "url"

    @classmethod
    def new(cls, url: str) -> "PagePropertyUrl":
        return cls(url=url)

    def __str__(self):
        return str(self.url)


@dataclass
class PagePropertyEmail(PageProperty):
    email: Optional[str] = None
    type: str = "email"

    @classmethod
    def new(cls, email: str) -> "PagePropertyEmail":
        return cls(email=email)

    def __str__(self):
        return str(self.email)


@dataclass
class PagePropertyPhoneNumber(PageProperty):
    phone_number: Optional[str] = None
    type: str = "phone_number"

    @classmethod
    def new(cls, phone_number: str) -> "PagePropertyPhoneNumber":
        return cls(phone_number=phone_number)

    def __str__(self):
        return str(self.phone_number)


@dataclass
class PagePropertyCreatedBy(PageProperty):
    created_by: User = field(default_factory=User)
    type: str = "created_by"

    def __str__(self):
        return str(self.created_by.name) if self.created_by else ""


@dataclass
class PagePropertyCreatedTime(PageProperty):
    created_time: str = None
    type: str = "created_time"

    def __str__(self):
        return str(self.created_time)


@dataclass
class PagePropertyLastEditedTime(PageProperty):
    last_edited_time: str = None
    type: str = "last_edited_time"

    def __str__(self):
        return str(self.last_edited_time)


@dataclass
class PagePropertyLastEditedBy(PageProperty):
    last_edited_by: User = field(default_factory=User)
    type: str = "last_edited_by"

    def __str__(self):
        return str(self.last_edited_by.name) if self.last_edited_by else ""


class PagePropertyFactory:
    """
    This class is a helper to create the proper PageProperty type automaticaly.
    """

    __rel_map = {
        "title": PagePropertyTitle,
        "rich_text": PagePropertyText,
        "number": PagePropertyNumber,
        "select": PagePropertySelect,
        "multi_select": PagePropertyMultiSelect,
        "date": PagePropertyDate,
        "people": PagePropertyPeople,
        "files": PagePropertyFiles,
        "checkbox": PagePropertyCheckbox,
        "url": PagePropertyUrl,
        "email": PagePropertyEmail,
        "phone_number": PagePropertyPhoneNumber,
        "formula": PagePropertyFormula,
        "relation": PagePropertyRelation,
        "rollup": PagePropertyRollup,
        "created_by": PagePropertyCreatedBy,
        "created_time": PagePropertyCreatedTime,
        "last_edited_time": PagePropertyLastEditedTime,
        "last_edited_by": PagePropertyLastEditedBy,
    }

    @staticmethod
    def new(data):
        data_type = data.get("type")
        if data_type and data_type in PagePropertyFactory.__rel_map:
            return from_dict(
                data_class=PagePropertyFactory.__rel_map[data_type], data=data
            )
        else:
            raise Exception(f'PageProperty "{data_type}" not implemented yet.')

    @staticmethod
    def new_default(type: str, payload=any, color=None):
        if type in ["select", "multi_select"]:
            return PagePropertyFactory.__rel_map[type].new(payload, color=color)
        else:
            return PagePropertyFactory.__rel_map[type].new(payload)


@dataclass
class Page(__BaseDataClass):
    properties: object
    parent: Parent
    blocks: Optional[List["Block"]] = field(default_factory=list)
    archived: bool = False
    # icon: Optional[Emoji] = None  # TODO: Handle File
    cover: Optional[File] = None
    created_time: str = None
    last_edited_time: str = None
    url: str = None
    object: str = "page"
    id: str = None

    """
    __post_init__ is called right after the object is created.

    We use it here to keep self.properties as a Dictionnary but we
    want to convert values as Data Classes instances.
    """

    def __post_init__(self):
        for k in self.properties:
            self.properties[k] = PagePropertyFactory.new(self.properties[k])

    def __repr__(self):
        try:
            display(self.df())  # noqa: F821
            return ""
        except:  # noqa: E722
            return str(self)

    def get_blocks(self):
        self.blocks = self.notion.blocks.children(self.id)
        return self.blocks

    def duplicate(self):
        page = deepcopy(self)
        page.id = None
        page.parent.type = None

        to_delete = []
        for col_name in page.properties:
            prop = page.properties[col_name]
            if prop.type in [
                "last_edited_time",
                "created_time",
                "created_by",
                "last_edited_by",
            ]:
                to_delete.append(col_name)
            elif getattr(prop, prop.type) is None:
                to_delete.append(col_name)

        for col_name in to_delete:
            del page.properties[col_name]

        block_tree = BlockTree(self.notion.blocks.get(self.id))
        new_page = page.create()
        block_tree.duplicate(new_page.id)

        return new_page

    def df(self, pivot=False):
        copied = deepcopy(self.properties)
        if pivot is False:
            d = []
            for i in copied:
                p = copied[i]

                d.append({"Name": i, "Type": p.type, "Value": str(p)})
            return pd.DataFrame(self.notion.to_dict(d))
        else:
            frame = {}
            for i in copied:
                p = copied[i]
                frame[i] = str(p)
            return pd.DataFrame(self.notion.to_dict([frame]))

    @classmethod
    def new(cls, page_id: str = None, database_id: str = None):
        parent = None
        if page_id:
            parent = Parent.new_page_parent(page_id=page_id)
        elif database_id:
            parent = Parent.new_database_parent(database_id=database_id)
        else:
            parent = Parent.new_workspace_parent()
        #  For some reasons, not explained in Notion API Documentation, when creating a new page
        # the property 'type' of a Parent object must be omitted.
        parent.type = None

        return cls(parent=parent, properties={})

    def add_block(self, block_type: str):
        new_block = Block.new(type=block_type)
        new_block = new_block.append_to(page_id=self.id)
        self.get_blocks()
        return new_block

    def update(self):
        ret = self.notion.pages.update(self)
        blocks_to_add = []
        for block in self.blocks:
            if not block.id:
                blocks_to_add.append(block)

        if len(blocks_to_add) > 0:
            self.notion.blocks.append(self.id, blocks_to_add)

        return ret

    def create(self):
        return self.notion.pages.create_from_page(self)

    """
    Properties setters
    """

    def __generic_property_setter(self, type: str, column_name, column_value):
        if column_name not in self.properties and self.parent.type == "database_id":
            db = self.notion.databases.retrieve(database_id=self.parent.database_id)
            db.add_property(column_name, type)
            db.update()
        if type in ["select", "multi_select"]:
            db = self.notion.databases.retrieve(database_id=self.parent.database_id)
            select_option = db.properties[column_name].get_option_by_name(column_value)
            color = None
            if select_option:
                color = select_option.color
            self.properties[column_name] = PagePropertyFactory.new_default(
                type, column_value, color=color
            )
        else:
            self.properties[column_name] = PagePropertyFactory.new_default(
                type, column_value
            )
        return self.properties[column_name]

    def title(self, *k):
        return self.__generic_property_setter("title", *k)

    def rich_text(self, *k):
        return self.__generic_property_setter("rich_text", *k)

    def number(self, *k):
        return self.__generic_property_setter("number", *k)

    def select(self, *k):
        return self.__generic_property_setter("select", *k)

    def multi_select(self, *k):
        return self.__generic_property_setter("multi_select", *k)

    def date(self, *k):
        return self.__generic_property_setter("date", *k)

    def people(self, *k):
        return self.__generic_property_setter("people", *k)

    def checkbox(self, *k):
        return self.__generic_property_setter("checkbox", *k)

    def link(self, *k):
        return self.__generic_property_setter("url", *k)

    def email(self, *k):
        return self.__generic_property_setter("email", *k)

    def phone_number(self, *k):
        return self.__generic_property_setter("phone_number", *k)

    """
    Block setters
    """

    def __generic_block_setter(self, type: str, payload):
        new_block = BlockTypeFactory.new_default(type, payload)
        self.blocks.append(new_block)
        return new_block

    def paragraph(self, *k):
        return self.__generic_block_setter("paragraph", *k)

    def heading_1(self, *k):
        return self.__generic_block_setter("heading_1", *k)

    def heading_2(self, *k):
        return self.__generic_block_setter("heading_2", *k)

    def heading_3(self, *k):
        return self.__generic_block_setter("heading_3", *k)

    def bulleted_list_item(self, *k):
        return self.__generic_block_setter("bulleted_list_item", *k)

    def numbered_list_item(self, *k):
        return self.__generic_block_setter("numbered_list_item", *k)

    def to_do(self, *k):
        return self.__generic_block_setter("to_do", *k)

    def toggle(self, *k):
        return self.__generic_block_setter("toggle", *k)

    def code(self, *k):
        return self.__generic_block_setter("code", *k)

    def embed(self, *k):
        return self.__generic_block_setter("embed", *k)

    def image(self, *k):
        return self.__generic_block_setter("image", *k)

    def video(self, *k):
        return self.__generic_block_setter("video", *k)

    def file(self, *k):
        return self.__generic_block_setter("file", *k)

    def pdf(self, *k):
        return self.__generic_block_setter("pdf", *k)

    def bookmark(self, *k):
        return self.__generic_block_setter("bookmark", *k)

    def equation(self, *k):
        return self.__generic_block_setter("equation", *k)

    def divider(self, *k):
        return self.__generic_block_setter("divider", *k)

    def table_of_contents(self, *k):
        return self.__generic_block_setter("table_of_contents", *k)

    def child_database(self, *k):
        return self.__generic_block_setter("child_database", *k)


"""
Block and Blocks properties.
"""


@dataclass
class Block(__BaseDataClass):
    type: str
    paragraph: Optional["BlockParagraph"] = None
    heading_1: Optional["BlockHeadingOne"] = None
    heading_2: Optional["BlockHeadingTwo"] = None
    heading_3: Optional["BlockHeadingThree"] = None
    callout: Optional["BlockCallout"] = None
    quote: Optional["BlockQuote"] = None
    bulleted_list_item: Optional["BlockBulletedListItem"] = None
    numbered_list_item: Optional["BlockNumberedListItem"] = None
    to_do: Optional["BlockToDo"] = None
    toggle: Optional["BlockToggle"] = None
    code: Optional["BlockCode"] = None
    child_page: Optional["BlockChildPage"] = None
    child_database: Optional["BlockChildDatabase"] = None
    embed: Optional["BlockEmbed"] = None
    image: Optional["File"] = None
    video: Optional["File"] = None
    file: Optional["File"] = None
    pdf: Optional["File"] = None
    bookmark: Optional["BlockBookmark"] = None
    equation: Optional["BlockEquation"] = None
    divider: Optional[dict] = None
    table_of_contents: Optional[dict] = None
    has_children: bool = False
    archived: bool = False
    created_time: str = None
    last_edited_time: str = None
    object: str = "block"
    id: str = None

    @classmethod
    def new(cls, type: str, prop: any = None):
        if prop:
            return from_dict(data_class=cls, data={"type": type, type: prop})
        return from_dict(data_class=cls, data={"type": type, type: {}})

    # def __post_init__(self):
    #     if self.has_children is True:
    #         self.children = self.notion.blocks.children(block_id=self.id)

    # TODO: To delete
    # def __post_init__(self):
    #   setattr(self, self.type, BlockTypeFactory.new(self))

    def append_to(self, page_id):
        return self.notion.blocks.append(page_id, self)

    def update(self):
        return self.notion.blocks.update(self)

    def delete(self):
        return self.notion.blocks.delete(self.id)


@dataclass
class BlockParagraph(__BaseDataClass):
    children: Optional[List[Block]]
    text: List[RichText] = field(default_factory=list)

    def add(self, content):
        self.text.append(RichText.new_text(content=content))

    @classmethod
    def new(cls, content: str) -> "BlockParagraph":
        return cls(children=[], text=[RichText.new(content)])


@dataclass
class BlockHeadingOne(__BaseDataClass):
    text: List[RichText] = field(default_factory=list)

    @classmethod
    def new(cls, content: str) -> "BlockHeadingOne":
        return cls(text=[RichText.new(content)])


@dataclass
class BlockHeadingTwo(__BaseDataClass):
    text: List[RichText] = field(default_factory=list)

    @classmethod
    def new(cls, content: str) -> "BlockHeadingTwo":
        return cls(text=[RichText.new(content)])


@dataclass
class BlockHeadingThree(__BaseDataClass):
    text: List[RichText] = field(default_factory=list)

    @classmethod
    def new(cls, content: str) -> "BlockHeadingThree":
        return cls(text=[RichText.new(content)])


@dataclass
class BlockCallout(__BaseDataClass):
    icon: Optional[Union[File, Emoji]]
    children: Optional[List[Block]]
    text: List[RichText] = field(default_factory=list)

    @classmethod
    def new(cls, content: str) -> "BlockCallout":
        return cls(icon=None, children=[], text=[RichText.new(content)])


@dataclass
class BlockQuote(__BaseDataClass):
    children: Optional[List[Block]]
    text: List[RichText] = field(default_factory=list)

    @classmethod
    def new(cls, content: str) -> "BlockQuote":
        return cls(children=[], text=[RichText.new(content)])


@dataclass
class BlockBulletedListItem(__BaseDataClass):
    children: Optional[List[Block]]
    text: List[RichText] = field(default_factory=list)

    @classmethod
    def new(cls, content: str) -> "BlockBulletedListItem":
        return cls(children=[], text=[RichText.new(content)])


@dataclass
class BlockNumberedListItem(__BaseDataClass):
    children: Optional[List[Block]]
    text: List[RichText] = field(default_factory=list)

    @classmethod
    def new(cls, content: str) -> "BlockNumberedListItem":
        return cls(children=[], text=[RichText.new(content)])


@dataclass
class BlockToDo(__BaseDataClass):
    checked: Optional[bool]
    children: Optional[List[Block]]
    text: List[RichText] = field(default_factory=list)

    @classmethod
    def new(cls, content: str) -> "BlockToDo":
        return cls(checked=False, children=[], text=[RichText.new(content)])


@dataclass
class BlockToggle(__BaseDataClass):
    children: Optional[List[Block]]
    text: List[RichText] = field(default_factory=list)

    @classmethod
    def new(cls, content: str) -> "BlockToggle":
        return cls(children=[], text=[RichText.new(content)])


@dataclass
class BlockCode(__BaseDataClass):
    language: Optional[str] = "plain text"
    text: List[RichText] = field(default_factory=list)

    @classmethod
    def new(cls, content: str) -> "BlockCode":
        return cls(text=[RichText.new(content)])


@dataclass
class BlockChildPage(__BaseDataClass):
    title: Optional[str] = "New page"


@dataclass
class BlockChildDatabase(__BaseDataClass):
    title: Optional[str] = ""

    @classmethod
    def new(cls, title: str) -> "BlockChildDatabase":
        return cls(title=title)


@dataclass
class BlockEmbed(__BaseDataClass):
    url: Optional[str] = ""

    @classmethod
    def new(cls, url: str) -> "BlockEmbed":
        return cls(url=url)


@dataclass
class BlockImage(__BaseDataClass):
    image: Optional[File] = field(default_factory=File)

    @classmethod
    def new(cls, url: str) -> "BlockImage":
        return cls(image=File.new(url))


@dataclass
class BlockVideo(__BaseDataClass):
    video: Optional[File] = field(default_factory=File)

    @classmethod
    def new(cls, url: str) -> "BlockVideo":
        return cls(video=File.new(url))


@dataclass
class BlockFile(__BaseDataClass):
    file: Optional[File] = field(default_factory=File)

    @classmethod
    def new(cls, url: str) -> "BlockFile":
        return cls(file=File.new(url))


@dataclass
class BlockPdf(__BaseDataClass):
    pdf: Optional[File] = field(default_factory=File)

    @classmethod
    def new(cls, url: str) -> "BlockPdf":
        return cls(pdf=File.new(url))


@dataclass
class BlockBookmark(__BaseDataClass):
    caption: List[RichText] = field(default_factory=list)
    url: str = ""

    @classmethod
    def new(cls, content: str) -> "BlockBookmark":
        return cls(caption=[RichText.new(content)])


@dataclass
class BlockEquation(__BaseDataClass):
    expression: str = ""

    @classmethod
    def new(cls, expression: str) -> "BlockEquation":
        return cls(expression=expression)


@dataclass
class BlockDivider(__BaseDataClass):
    divider: object = field(default_factory=dict)

    @classmethod
    def new(cls) -> "BlockDivider":
        return cls()


@dataclass
class BlockTableOfContents(__BaseDataClass):
    table_of_contents: object = field(default_factory=dict)

    @classmethod
    def new(cls) -> "BlockTableOfContents":
        return cls()


class BlockTypes(Enum):
    paragraph = "paragraph"
    heading_1 = "heading_1"
    heading_2 = "heading_2"
    heading_3 = "heading_3"
    callout = "callout"
    quote = "quote"
    bulleted_list_item = "bulleted_list_item"
    numbered_list_item = "numbered_list_item"
    to_do = "to_do"
    toggle = "toggle"
    code = "code"
    child_page = "child_page"
    child_database = "child_database"
    embed = "embed"
    image = "image"
    video = "video"
    file = "file"
    pdf = "pdf"
    bookmark = "bookmark"
    equation = "equation"
    divider = "divider"
    table_of_contents = "table_of_contents"
    unsupported = "unsupported"


class BlockTypeFactory:
    """
    This class is a helper to create the proper BlockType automaticaly.
    """

    __rel_map = {
        "paragraph": BlockParagraph,
        "heading_1": BlockHeadingOne,
        "heading_2": BlockHeadingTwo,
        "heading_3": BlockHeadingThree,
        "callout": BlockCallout,  # Seems to be missing documentation as of 21/10/17
        "quote": BlockQuote,  # Seems to be missing documentation as of 21/10/17
        "bulleted_list_item": BlockBulletedListItem,
        "numbered_list_item": BlockNumberedListItem,
        "to_do": BlockToDo,
        "toggle": BlockToggle,
        "code": BlockCode,  # Seems to be missing documentation as of 21/10/17
        "child_page": BlockChildPage,
        "child_database": BlockChildDatabase,
        "embed": BlockEmbed,
        "image": File,
        # "video": BlockVideo,
        "video": File,
        "file": File,
        "pdf": File,
        "bookmark": BlockBookmark,
        "equation": BlockEquation,  # Seems to be missing documentation as of 21/10/17
        "divider": dict,  # Seems to be missing documentation as of 21/10/17
        "table_of_contents": dict,  # Seems to be missing documentation as of 21/10/17
        # "unsupported": BlockUnsupported
    }

    # @staticmethod
    # def new(data):
    #    data_type = getattr(data, 'type')
    #    if data_type and data_type in BlockTypeFactory.__rel_map:
    #        return from_dict(data_class=BlockTypeFactory.__rel_map[data_type], data=getattr(data, data_type))
    #    else:
    #        raise Exception(f'BlockType "{data_type}" not implemented yet.')

    @staticmethod
    def new_default(type: str, payload: any):
        if type in ["divider", "table_of_contents"]:
            prop = BlockTypeFactory.__rel_map[type].new()
        else:
            prop = BlockTypeFactory.__rel_map[type].new(payload)
        return Block.new(type, prop)


"""Dataclass json encoder"""


class EnhancedJSONEncoder(json.JSONEncoder):
    def default(self, o):
        if dataclasses.is_dataclass(o):
            return dataclasses.asdict(o)
        return super().default(o)


def del_none(d):
    """
    Delete keys with the value ``None`` in a dictionary, recursively.

    This alters the input so you may wish to ``copy`` the dict first.
    """
    # For Python 3, write `list(d.items())`; `d.items()` won’t work
    # For Python 2, write `d.items()`; `d.iteritems()` won’t work
    if type(d) is dict:
        for key, value in list(d.items()):
            if value is None:
                del d[key]
            elif isinstance(value, dict) or isinstance(value, list):
                del_none(value)
    elif type(d) is list:
        for e in d:
            del_none(e)
    return d  # For convenience


class BlockTree:
    block: Block = None
    children: List["BlockTree"] = None

    def __init__(self, block: Block):
        self.block = block
        if self.block.has_children is True:
            self.children = []
            blocks = Notion.instance().blocks.children(self.block.id)
            for b in blocks:
                self.children.append(BlockTree(b))

    def copy(self):
        copied_block = deepcopy(self.block)
        copied_block.id = None
        copied_block.created_time = None
        copied_block.last_edited_time = None
        return copied_block

    def duplicate_children(self, parent_id):
        if self.children is not None:
            to_append = []
            for child in self.children:
                to_append.append(child.copy())
            new_blocks = Notion.instance().block.append(parent_id, to_append)
            count = 0
            for child in self.children:
                child.duplicate_children(new_blocks[count].id)
                count = count + 1

    def duplicate(self, parent_id):
        if self.children is not None:
            to_append = []
            for child in self.children:
                to_append.append(child.copy())
            new_blocks = Notion.instance().block.append(parent_id, to_append)

            threads = []
            count = 0
            for child in self.children:
                x = threading.Thread(
                    target=child.duplicate_children, args=(new_blocks[count].id,)
                )
                threads.append(x)
                x.start()
                count = count + 1

            for t in threads:
                t.join()
