"""Models related to the Tibia.com character page."""
import datetime
import re
import urllib.parse
from collections import OrderedDict
from typing import List, Optional, TYPE_CHECKING

from tibiapy import abc
from tibiapy.enums import AccountStatus, Sex, Vocation
from tibiapy.errors import InvalidContent
from tibiapy.house import CharacterHouse
from tibiapy.utils import (parse_popup, parse_tibia_date, parse_tibia_datetime, parse_tibiacom_content, split_list,
                           try_datetime, try_enum)

if TYPE_CHECKING:
    import bs4

# Extracts the scheduled deletion date of a character."""
deleted_regexp = re.compile(r'([^,]+), will be deleted at (.*)')
# Extracts the death's level and killers.
death_regexp = re.compile(r'Level (?P<level>\d+) by (?P<killers>.*)\.</td>')
# From the killers list, filters out the assists.
death_assisted = re.compile(r'(?P<killers>.+)\.<br/>Assisted by (?P<assists>.+)')
# From a killer entry, extracts the summoned creature
death_summon = re.compile(r'(?P<summon>.+) of <a[^>]+>(?P<name>[^<]+)</a>')
link_search = re.compile(r'<a[^>]+>[^<]+</a>')
# Extracts the contents of a tag
link_content = re.compile(r'>([^<]+)<')

house_regexp = re.compile(r'paid until (.*)')

title_regexp = re.compile(r'(.*)\((\d+) titles? unlocked\)')
badge_popup_regexp = re.compile(r"\$\(this\),\s+'([^']+)',\s+'([^']+)',")

__all__ = (
    "AccountBadge",
    "AccountInformation",
    "Achievement",
    "Character",
    "Death",
    "GuildMembership",
    "Killer",
    "OtherCharacter",
    "OnlineCharacter",
)


class AccountBadge(abc.Serializable):
    """A displayed account badge in the character's information.

    Attributes
    ----------
    name: :class:`str`
        The name of the badge.
    icon_url: :class:`str`
        The URL to the badge's icon.
    description: :class:`str`
        The description of the badge.
    """

    __slots__ = (
        "name",
        "icon_url",
        "description",
    )

    def __init__(self, name, icon_url, description):
        self.name: str = name
        self.icon_url: str = icon_url
        self.description: str = description

    def __repr__(self):
        return f"<{self.__class__.__name__} name={self.name!r} description={self.description!r}>"


class AccountInformation(abc.Serializable):
    """Contains the information of a character's account.

    This is only visible if the character is not marked as hidden.

    Attributes
    ----------
    created: :class:`datetime.datetime`
        The date when the account was created.
    position: :class:`str`, optional
        The special position of this account, if any.
    loyalty_title: :class:`str`, optional
        The loyalty title of the account, if any.
    """

    __slots__ = (
        "created",
        "loyalty_title",
        "position",
    )

    def __init__(self, created, loyalty_title=None, position=None):
        self.created: datetime.datetime = try_datetime(created)
        self.loyalty_title: Optional[str] = loyalty_title
        self.position: Optional[str] = position

    def __repr__(self):
        return f"<{self.__class__.__name__} created={self.created!r} loyalty_title={self.loyalty_title!r}>"


class Achievement(abc.Serializable):
    """Represents an achievement listed on a character's page.

    Attributes
    ----------
    name: :class:`str`
        The name of the achievement.
    grade: :class:`int`
        The grade of the achievement, also known as stars.
    secret: :class:`bool`
        Whether the achievement is secret or not.
    """

    __slots__ = (
        "name",
        "grade",
        "secret",
    )

    def __init__(self, name, grade, secret=False):
        self.name: str = name
        self.grade = int(grade)
        self.secret: bool = secret

    def __repr__(self):
        return f"<{self.__class__.__name__} name={self.name!r} grade={self.grade} secret={self.secret}>"


class Character(abc.BaseCharacter, abc.Serializable):
    """A full character from Tibia.com, obtained from its character page.

    Attributes
    ----------
    name: :class:`str`
        The name of the character.
    traded: :class:`bool`
        If the character was traded in the last 6 months.
    deletion_date: :class:`datetime.datetime`, optional
        The date when the character will be deleted if it is scheduled for deletion. Will be :obj:`None` otherwise.
    former_names: :class:`list` of :class:`str`
        Previous names of the character.
    title: :class:`str`, optional
        The character's selected title, if any.
    unlocked_titles: :class:`int`
        The number of titles the character has unlocked.
    sex: :class:`Sex`
        The character's sex.
    vocation: :class:`Vocation`
        The character's vocation.
    level: :class:`int`
        The character's level.
    achievement_points: :class:`int`
        The total of achievement points the character has.
    world: :class:`str`
        The character's current world.
    former_world: :class:`str`, optional
        The previous world the character was in, in the last 6 months.
    residence: :class:`str`
        The current hometown of the character.
    married_to: :class:`str`, optional
        The name of the character's spouse. It will be :obj:`None` if not married.
    houses: :class:`list` of :class:`CharacterHouse`
        The houses currently owned by the character.
    guild_membership: :class:`GuildMembership`, optional
        The guild the character is a member of. It will be :obj:`None` if the character is not in a guild.
    last_login: :class:`datetime.datetime`, optional
        The last time the character logged in. It will be :obj:`None` if the character has never logged in.
    position: :class:`str`, optional
        The position of the character (e.g. CipSoft Member), if any.
    comment: :class:`str`, optional
        The displayed comment.
    account_status: :class:`AccountStatus`
        Whether the character's account is Premium or Free.
    account_badges: :class:`list` of :class:`AccountBadge`
        The displayed account badges.
    achievements: :class:`list` of :class:`Achievement`
        The achievements chosen to be displayed.
    deaths: :class:`list` of :class:`Death`
        The character's recent deaths.
    deaths_truncated: :class:`bool`
        Whether the character's deaths are truncated or not.

        In some cases, there are more deaths in the last 30 days than what can be displayed.
    account_information: :class:`AccountInformation`, optional
        The character's account information. If the character is hidden, this will be :obj:`None`.
    other_characters: :class:`list` of :class:`OtherCharacter`
        Other characters in the same account.

        It will be empty if the character is hidden, otherwise, it will contain at least the character itself.
    """

    __slots__ = (
        "name",
        "former_names",
        "traded",
        "sex",
        "title",
        "unlocked_titles",
        "vocation",
        "level",
        "achievement_points",
        "world",
        "former_world",
        "residence",
        "married_to",
        "houses",
        "guild_membership",
        "last_login",
        "account_status",
        "position",
        "comment",
        "account_badges",
        "achievements",
        "deaths",
        "deaths_truncated",
        "account_information",
        "other_characters",
        "deletion_date",
    )

    _serializable_properties = (
        "deleted",
        "hidden",
    )

    def __init__(self, name=None, world=None, vocation=None, level=0, sex=None, **kwargs):
        self.name: str = name
        self.traded: bool = kwargs.get("traded", False)
        self.former_names: List[str] = kwargs.get("former_names", [])
        self.title: Optional[str] = kwargs.get("title")
        self.unlocked_titles = int(kwargs.get("unlocked_titles", 0))
        self.sex = try_enum(Sex, sex)
        self.vocation = try_enum(Vocation, vocation)
        self.level = int(level)
        self.achievement_points = int(kwargs.get("achievement_points", 0))
        self.world: str = world
        self.former_world: Optional[str] = kwargs.get("former_world")
        self.residence: str = kwargs.get("residence")
        self.married_to: Optional[str] = kwargs.get("married_to")
        self.houses: List[CharacterHouse] = kwargs.get("houses", [])
        self.guild_membership: Optional[GuildMembership] = kwargs.get("guild_membership")
        self.last_login = try_datetime(kwargs.get("last_login"))
        self.account_status = try_enum(AccountStatus, kwargs.get("account_status"))
        self.position: Optional[str] = kwargs.get("position")
        self.comment: Optional[str] = kwargs.get("comment")
        self.account_badges: List[AccountBadge] = kwargs.get("account_badges", [])
        self.achievements: List[Achievement] = kwargs.get("achievements", [])
        self.deaths: List[Death] = kwargs.get("deaths", [])
        self.deaths_truncated: bool = kwargs.get("deaths", False)
        self.account_information: Optional[AccountInformation] = kwargs.get("account_information")
        self.other_characters: List[OtherCharacter] = kwargs.get("other_characters", [])
        self.deletion_date = try_datetime(kwargs.get("deletion_date"))

    def __repr__(self):
        return f"<{self.__class__.__name__} name={self.name!r} world={self.world!r} vocation={self.vocation!r} " \
               f"level={self.level} sex={self.sex!r}>"

    # region Properties
    @property
    def deleted(self) -> bool:
        """:class:`bool`: Whether the character is scheduled for deletion or not."""
        return self.deletion_date is not None

    @property
    def guild_name(self) -> Optional[str]:
        """:class:`str`, optional: The name of the guild the character belongs to, or :obj:`None`."""
        return self.guild_membership.name if self.guild_membership else None

    @property
    def guild_rank(self) -> Optional[str]:
        """:class:`str`, optional: The character's rank in the guild they belong to, or :obj:`None`."""
        return self.guild_membership.rank if self.guild_membership else None

    @property
    def guild_url(self) -> Optional[str]:
        """:class:`str`, optional: The character's rank in the guild they belong to, or :obj:`None`."""
        return abc.BaseGuild.get_url(self.guild_membership.name) if self.guild_membership else None

    @property
    def hidden(self) -> bool:
        """:class:`bool`: Whether this is a hidden character or not."""
        return len(self.other_characters) == 0

    @property
    def married_to_url(self) -> Optional[str]:
        """:class:`str`, optional: The URL to the husband/spouse information page on Tibia.com, if applicable."""
        return self.get_url(self.married_to) if self.married_to else None
    # endregion

    # region Public methods
    @classmethod
    def from_content(cls, content):
        """Create an instance of the class from the html content of the character's page.

        Parameters
        ----------
        content: :class:`str`
            The HTML content of the page.

        Returns
        -------
        :class:`Character`
            The character contained in the page, or None if the character doesn't exist

        Raises
        ------
        InvalidContent
            If content is not the HTML of a character's page.
        """
        parsed_content = parse_tibiacom_content(content)
        tables = cls._parse_tables(parsed_content)
        char = Character()
        if not tables:
            messsage_table = parsed_content.find("div", {"class": "TableContainer"})
            if messsage_table and "Could not find character" in messsage_table.text:
                return None
        if "Character Information" in tables.keys():
            char._parse_character_information(tables["Character Information"])
        else:
            raise InvalidContent("content does not contain a tibia.com character information page.")
        char._parse_achievements(tables.get("Account Achievements", []))
        if "Account Badges" in tables:
            char._parse_badges(tables["Account Badges"])
        char._parse_deaths(tables.get("Character Deaths", []))
        char._parse_account_information(tables.get("Account Information", []))
        char._parse_other_characters(tables.get("Characters", []))
        return char
    # endregion

    # region Private methods
    def _parse_account_information(self, rows):
        """Parse the character's account information.

        Parameters
        ----------
        rows: :class:`list` of :class:`bs4.Tag`, optional
            A list of all rows contained in the table.
        """
        acc_info = {}
        if not rows:
            return
        for row in rows:
            cols_raw = row.find_all('td')
            cols = [ele.text.strip() for ele in cols_raw]
            field, value = cols
            field = field.replace("\xa0", "_").replace(" ", "_").replace(":", "").lower()
            value = value.replace("\xa0", " ")
            acc_info[field] = value
        created = parse_tibia_datetime(acc_info["created"])
        loyalty_title = None if acc_info["loyalty_title"] == "(no title)" else acc_info["loyalty_title"]
        position = acc_info.get("position")
        self.account_information = AccountInformation(created, loyalty_title, position)

    def _parse_achievements(self, rows):
        """Parse the character's displayed achievements.

        Parameters
        ----------
        rows: :class:`list` of :class:`bs4.Tag`
            A list of all rows contained in the table.
        """
        for row in rows:
            cols = row.find_all('td')
            if len(cols) != 2:
                continue
            field, value = cols
            grade = str(field).count("achievement-grade-symbol")
            name = value.text.strip()
            secret_image = value.find("img")
            secret = False
            if secret_image:
                secret = True
            self.achievements.append(Achievement(name, grade, secret))

    def _parse_badges(self, rows):
        """Parse the character's displayed badges.

        Parameters
        ----------
        rows: :class:`list` of :class:`bs4.Tag`
            A list of all rows contained in the table.
        """
        row = rows[0]
        columns = row.find_all('td')
        for column in columns:
            popup_span = column.find("span", attrs={"class": "HelperDivIndicator"})
            if not popup_span:
                # Badges are visible, but none selected.
                return
            popup = parse_popup(popup_span['onmouseover'])
            name = popup[0]
            description = popup[1].text
            icon_image = column.find("img")
            icon_url = icon_image['src']
            self.account_badges.append(AccountBadge(name, icon_url, description))

    def _parse_character_information(self, rows):
        """
        Parse the character's basic information and applies the found values.

        Parameters
        ----------
        rows: :class:`list` of :class:`bs4.Tag`
            A list of all rows contained in the table.
        """
        int_rows = ["level", "achievement_points"]
        char = {}
        houses = []
        for row in rows:
            cols_raw = row.find_all('td')
            cols = [ele.text.strip() for ele in cols_raw]
            field, value = cols
            field = field.replace("\xa0", "_").replace(" ", "_").replace(":", "").lower()
            value = value.replace("\xa0", " ")
            # This is a special case cause we need to see the link
            if field == "house":
                house_text = value
                m = house_regexp.search(house_text)
                if not m:
                    continue
                paid_until = m.group(1)
                paid_until_date = parse_tibia_date(paid_until)
                house_link = cols_raw[1].find('a')
                if not house_link:
                    continue
                url = urllib.parse.urlparse(house_link["href"])
                query = urllib.parse.parse_qs(url.query)
                houses.append({"id": int(query["houseid"][0]), "name": house_link.text.strip(),
                               "town": query["town"][0], "paid_until": paid_until_date})
                continue
            if field == "guild_membership":
                guild_link = cols_raw[1].find('a')
                rank = value.split("of the")[0]
                char["guild_membership"] = GuildMembership(guild_link.text.replace("\xa0", " "), rank.strip())

                continue
            if field in int_rows:
                value = int(value)
            char[field] = value

        # If the character is deleted, the information is fouund with the name, so we must clean it
        m = deleted_regexp.match(char["name"])
        if m:
            char["name"] = m.group(1)
            char["deletion_date"] = parse_tibia_datetime(m.group(2))

        if "(traded)" in char["name"]:
            char["name"] = char["name"].replace("(traded)", "").strip()
            char["traded"] = True

        if "former_names" in char:
            former_names = [fn.strip() for fn in char["former_names"].split(",")]
            char["former_names"] = former_names

        if "never" in char["last_login"]:
            char["last_login"] = None
        else:
            char["last_login"] = parse_tibia_datetime(char["last_login"])

        m = title_regexp.match(char.get("title", ""))
        if m:
            name = m.group(1).strip()
            unlocked = int(m.group(2))
            if name == "None":
                name = None
            char["title"] = name
            char["unlocked_titles"] = unlocked

        char["vocation"] = try_enum(Vocation, char["vocation"])
        char["sex"] = try_enum(Sex, char["sex"])
        char["account_status"] = try_enum(AccountStatus, char["account_status"])

        for k, v in char.items():
            try:
                setattr(self, k, v)
            except AttributeError:
                # This means that there is a attribute in the character's information table that does not have a
                # corresponding class attribute.
                pass
        self.houses = [CharacterHouse(h["id"], h["name"], self.world, h["town"], self.name, h["paid_until"])
                       for h in houses]

    def _parse_deaths(self, rows):
        """Parse the character's recent deaths.

        Parameters
        ----------
        rows: :class:`list` of :class:`bs4.Tag`
            A list of all rows contained in the table.
        """
        for row in rows:
            cols = row.find_all('td')
            if len(cols) != 2:
                self.deaths_truncated = True
                break
            death_time_str = cols[0].text.replace("\xa0", " ").strip()
            death_time = parse_tibia_datetime(death_time_str)
            death = str(cols[1])
            death_info = death_regexp.search(death)
            if death_info:
                level = int(death_info.group("level"))
                killers_desc = death_info.group("killers")
            else:
                continue
            death = Death(self.name, level, time=death_time)
            assists_name_list = []
            # Check if the killers list contains assists
            assist_match = death_assisted.search(killers_desc)
            if assist_match:
                # Filter out assists
                killers_desc = assist_match.group("killers")
                # Split assists into a list.
                assists_desc = assist_match.group("assists")
                assists_name_list = link_search.findall(assists_desc)
            killers_name_list = split_list(killers_desc)
            for killer in killers_name_list:
                killer = killer.replace("\xa0", " ")
                killer_dict = self._parse_killer(killer)
                death.killers.append(Killer(**killer_dict))
            for assist in assists_name_list:
                # Extract names from character links in assists list.
                assist_dict = {"name": link_content.search(assist).group(1), "player": True}
                death.assists.append(Killer(**assist_dict))
            try:
                self.deaths.append(death)
            except ValueError:
                # Some pvp deaths have no level, so they are raising a ValueError, they will be ignored for now.
                continue

    @classmethod
    def _parse_killer(cls, killer):
        """Parse a killer into a dictionary.

        Parameters
        ----------
        killer: :class:`str`
            The killer's raw HTML string.

        Returns
        -------
        :class:`dict`: A dictionary containing the killer's info.
        """
        # If the killer contains a link, it is a player.
        if "href" in killer:
            m = link_content.search(killer)
            killer_dict = {"name": m.group(1), "player": True}
        else:
            killer_dict = {"name": killer, "player": False}
        # Check if it contains a summon.
        m = death_summon.search(killer)
        if m:
            killer_dict["summon"] = m.group("summon")
        return killer_dict

    def _parse_other_characters(self, rows):
        """Parse the character's other visible characters.

        Parameters
        ----------
        rows: :class:`list` of :class:`bs4.Tag`
            A list of all rows contained in the table.
        """
        for row in rows[1:]:
            cols_raw = row.find_all('td')
            cols = [ele.text.strip() for ele in cols_raw]
            if len(cols) != 4:
                continue
            name, world, status, *__ = cols
            _, *name = name.replace("\xa0", " ").split(" ")
            name = " ".join(name)
            traded = False
            if "(traded)" in name:
                name = name.replace("(traded)", "").strip()
                traded = True
            main_img = cols_raw[0].find('img')
            main = False
            if main_img and main_img['title'] == "Main Character":
                main = True
            position = None
            if "CipSoft Member" in status:
                position = "CipSoft Member"
            self.other_characters.append(OtherCharacter(name, world, "online" in status, "deleted" in status, main,
                                                        position, traded))

    @classmethod
    def _parse_tables(cls, parsed_content):
        """
        Parse the information tables contained in a character's page.

        Parameters
        ----------
        parsed_content: :class:`bs4.BeautifulSoup`
            A :class:`BeautifulSoup` object containing all the content.

        Returns
        -------
        :class:`OrderedDict`[str, :class:`list`of :class:`bs4.Tag`]
            A dictionary containing all the table rows, with the table headers as keys.
        """
        tables = parsed_content.find_all('table', attrs={"width": "100%"})
        output = OrderedDict()
        for table in tables:
            container = table.find_parent("div", {"class": "TableContainer"})
            if container:
                caption_container = container.find("div", {"class": "CaptionContainer"})
                title = caption_container.text.strip()
                offset = 0
            else:
                title = table.find("td").text.strip()
                offset = 1
            output[title] = table.find_all("tr")[offset:]
        return output
    # endregion


class Death(abc.Serializable):
    """A character's death.

    Attributes
    -----------
    name: :class:`str`
        The name of the character this death belongs to.
    level: :class:`int`
        The level at which the death occurred.
    killers: :class:`list` of :class:`Killer`
        A list of all the killers involved.
    assists: :class:`list` of :class:`Killer`
        A list of characters that were involved, without dealing damage.
    time: :class:`datetime.datetime`
        The time at which the death occurred.
    """

    __slots__ = (
        "level",
        "killers",
        "time",
        "assists",
        "name",
    )

    _serializable_properties = (
        "by_player"
    )

    def __init__(self, name=None, level=0, **kwargs):
        self.name: str = name
        self.level: int = level
        self.killers: List[Killer] = kwargs.get("killers", [])
        self.assists: List[Killer] = kwargs.get("assists", [])
        self.time: datetime.datetime = try_datetime(kwargs.get("time"))

    def __repr__(self):
        attributes = ""
        for attr in self.__slots__:
            v = getattr(self, attr)
            if isinstance(v, int) and v == 0 and not isinstance(v, bool):
                continue
            if isinstance(v, list) and len(v) == 0:
                continue
            if v is None:
                continue
            attributes += f" {attr}={v!r}"
        return f"<{self.__class__.__name__} {attributes})"

    # region Properties
    @property
    def by_player(self):
        """:class:`bool`: Whether the kill involves other characters."""
        return any([k.player and self.name != k.name for k in self.killers])

    @property
    def killer(self):
        """:class:`Killer`: The first killer in the list.

        This is usually the killer that gave the killing blow.
        """
        return self.killers[0] if self.killers else None
    # endregion


class GuildMembership(abc.BaseGuild, abc.Serializable):
    """The guild information of a character.

    Attributes
    ----------
    name: :class:`str`
        The name of the guild.
    rank: :class:`str`
        The name of the rank the member has.
    title: :class:`str`, optional
        The title of the member in the guild. This is only available for characters in the forums section.
    """

    __slots__ = (
        "name",
        "rank",
        "title",
    )

    def __init__(self, name, rank, title=None):
        self.name: str = name
        self.rank: str = rank
        self.title: Optional[str] = title

    def __repr__(self):
        return f"<{self.__class__.__name__} name={self.name!r} rank={self.rank!r}>"


class Killer(abc.Serializable):
    """Represents a killer.

    A killer can be:

    a) A creature.
    b) A character.
    c) A creature summoned by a character.

    Attributes
    -----------
    name: :class:`str`
        The name of the killer. In the case of summons, the name belongs to the owner.
    player: :class:`bool`
        Whether the killer is a player or not.
    summon: :class:`str`, optional
        The name of the summoned creature, if applicable.
    """

    __slots__ = (
        "name",
        "player",
        "summon",
    )

    def __init__(self, name, player=False, summon=None):
        self.name: str = name
        self.player: bool = player
        self.summon: Optional[str] = summon

    def __repr__(self):
        summon = f" summon={self.summon!r}" if self.summon else ""
        return f"<{self.__class__.__name__} name={self.name!r} player={self.player}{summon}>"

    @property
    def url(self):
        """:class:`str`, optional: The URL of the character’s information page on Tibia.com, if applicable."""
        return Character.get_url(self.name) if self.player else None


class OnlineCharacter(abc.BaseCharacter, abc.Serializable):
    """An online character in the world's page.

    Attributes
    ----------
    name: :class:`str`
        The name of the character.
    world: :class:`str`
        The name of the world.
    vocation: :class:`Vocation`
        The vocation of the character.
    level: :class:`int`
        The level of the character.
    """

    __slots__ = (
        "name",
        "world",
        "vocation",
        "level",
    )

    def __init__(self, name, world, level, vocation):
        self.name: str = name
        self.world: str = world
        self.level = int(level)
        self.vocation = try_enum(Vocation, vocation)

    def __repr__(self):
        return f"<{self.__class__.__name__} name={self.name!r} level={self.level} vocation={self.vocation!r}>"


class OtherCharacter(abc.BaseCharacter, abc.Serializable):
    """A character listed in the characters section of a character's page.

    These are only shown if the character is not hidden, and only characters that are not hidden are shown here.

    Attributes
    ----------
    name: :class:`str`
        The name of the character.
    world: :class:`str`
        The name of the world.
    online: :class:`bool`
        Whether the character is online or not.
    deleted: :class:`bool`
        Whether the character is scheduled for deletion or not.
    traded: :class:`bool`
        Whether the character has been traded recently or not.
    main: :class:`bool`
        Whether this is the main character or not.
    position: :class:`str`
        The character's official position, if any.
    """

    __slots__ = (
        "name",
        "world",
        "online",
        "deleted",
        "traded",
        "main",
        "position",
    )

    def __init__(self, name, world, online=False, deleted=False, main=False, position=None, traded=False):
        self.name: str = name
        self.world: str = world
        self.online: bool = online
        self.deleted: bool = deleted
        self.main: bool = main
        self.traded: bool = traded
        self.position: Optional[str] = position

    def __repr__(self):
        return f"<{self.__class__.__name__} name={self.name!r} world={self.world!r} online={self.online!r} " \
               f"main={self.main} position={self.position!r}>"
