import abc
import re
from collections import defaultdict
from itertools import repeat
from typing import Any, Dict, List, Set, Union

from lutris import settings
from lutris.database import games as games_db
from lutris.database import sql
from lutris.gui.widgets import NotificationSource
from lutris.util.strings import get_natural_sort_key

CATEGORIES_UPDATED = NotificationSource()


class _SmartCategory(abc.ABC):
    """Abstract class to define smart categories. Smart categories are automatically defined based on a rule."""

    @abc.abstractmethod
    def get_name(self) -> str:
        pass

    def get_game_ids(self) -> Set[str]:
        return set(game["id"] for game in self.get_games())

    @abc.abstractmethod
    def get_games(self) -> List[Any]:
        pass


class _SmartUncategorizedCategory(_SmartCategory):
    """A SmartCategory that resolves to all uncategorized games."""

    def get_name(self) -> str:
        return ".uncategorized"

    def get_game_ids(self) -> Set[str]:
        return get_uncategorized_game_ids()

    def get_games(self) -> List[Any]:
        return get_uncategorized_games()


# All smart categories should be added to this variable.
# TODO: Expose a way for the users to define new smart categories.
_SMART_CATEGORIES: List[_SmartCategory] = [_SmartUncategorizedCategory()]


def strip_category_name(name):
    """ "This strips the name given, and also removes extra internal whitespace."""
    name = (name or "").strip()
    if not is_reserved_category(name):
        name = re.sub(" +", " ", name)  # Remove excessive whitespaces
    return name


def is_reserved_category(name):
    """True if name is None, blank or is a name Lutris uses internally, or
    starts with '.' for future expansion."""
    return not name or name[0] == "." or name in ["all", "favorite"]


def get_categories() -> List[Dict[str, Union[int, str]]]:
    """Get the list of every category in database."""
    # Categories look like [{"id": 1, "name": "My Category"}, ...]
    return sql.db_select(settings.DB_PATH, "categories")


def get_all_games_categories():
    games_categories = defaultdict(list)
    for row in sql.db_select(settings.DB_PATH, "games_categories"):
        games_categories[row["game_id"]].append(row["category_id"])
    return games_categories


def get_category_by_name(name):
    """Return a category by name"""
    categories = sql.db_select(settings.DB_PATH, "categories", condition=("name", name))
    if categories:
        return categories[0]


def get_category_by_id(category_id):
    """Return a category by name"""
    categories = sql.db_select(settings.DB_PATH, "categories", condition=("id", category_id))
    if categories:
        return categories[0]


def normalized_category_names(name: str, subname_allowed: bool = False) -> List[str]:
    """Searches for a category name case-insensitively and returns all matching names;
    if none match, it just returns 'name' as is.

    If subname_allowed is true but name is not a match for any category, we'll look for
    any category that contains the name as a substring instead before falling back to
    'name' itself."""
    query = "SELECT name FROM categories WHERE name=? COLLATE NOCASE"
    parameters = (name,)
    names = [cat["name"] for cat in sql.db_query(settings.DB_PATH, query, parameters)]

    if not names and subname_allowed:
        query = "SELECT name FROM categories WHERE name LIKE ? COLLATE NOCASE"
        parameters = (f"%{name}%",)
        names = [cat["name"] for cat in sql.db_query(settings.DB_PATH, query, parameters)]

    return names or [name]


def get_game_ids_for_categories(included_category_names=None, excluded_category_names=None):
    """Get the ids of games in database."""
    filters = []
    parameters = []

    if included_category_names:
        # Query that finds games in the included categories
        query = (
            "SELECT games.id FROM games "
            "INNER JOIN games_categories ON games.id = games_categories.game_id "
            "INNER JOIN categories ON categories.id = games_categories.category_id"
        )
        filters.append("categories.name IN (%s)" % ", ".join(repeat("?", len(included_category_names))))
        parameters.extend(included_category_names)
    else:
        # Or, if you listed none, we fall back to all games
        query = "SELECT games.id FROM games"

    if excluded_category_names:
        # Sub-query to exclude the excluded categories, if any.
        exclude_filter = (
            "NOT EXISTS(SELECT * FROM games_categories AS gc "
            "INNER JOIN categories AS c ON gc.category_id = c.id "
            "WHERE gc.game_id = games.id "
            "AND c.name IN (%s))" % ", ".join(repeat("?", len(excluded_category_names)))
        )
        filters.append(exclude_filter)
        parameters.extend(excluded_category_names)

    if filters:
        query += " WHERE %s" % " AND ".join(filters)

    result = set(game["id"] for game in sql.db_query(settings.DB_PATH, query, tuple(parameters)))
    for smart_cat in _SMART_CATEGORIES:
        if excluded_category_names is not None and smart_cat.get_name() in excluded_category_names:
            continue
        if included_category_names is not None and smart_cat.get_name() not in included_category_names:
            continue
        result |= smart_cat.get_game_ids()

    return list(sorted(result))


def get_uncategorized_game_ids() -> Set[str]:
    """Returns the ids of games that are in no categories. We do not count
    the 'favorites' category, but we do count '.hidden'- hidden games are hidden
    from this too."""
    query = (
        "SELECT games.id FROM games WHERE NOT EXISTS("
        "SELECT * FROM games_categories "
        "INNER JOIN categories ON categories.id = games_categories.category_id "
        "AND categories.name NOT IN ('all', 'favorite') "
        "WHERE games.id = games_categories.game_id)"
    )
    uncategorized = sql.db_query(settings.DB_PATH, query)
    return set(row["id"] for row in uncategorized)


def get_uncategorized_games() -> List[Any]:
    """Return a list of currently running games"""
    games = games_db.get_games_by_ids(get_uncategorized_game_ids())

    def get_key(g: Dict[str, Any]):
        """Sort in the default order for Lutris- installed games first, then by name."""
        name = str(g.get("name") or "")
        installed = bool(g.get("installed"))
        return not installed, get_natural_sort_key(name)

    games.sort(key=get_key)
    return games


def get_categories_in_game(game_id):
    """Get the categories of a game in database."""
    query = (
        "SELECT categories.name FROM categories "
        "JOIN games_categories ON categories.id = games_categories.category_id "
        "JOIN games ON games.id = games_categories.game_id "
        "WHERE games.id=?"
    )
    return [category["name"] for category in sql.db_query(settings.DB_PATH, query, (game_id,))]


def add_category(category_name, no_signal: bool = False):
    """Add a category to the database"""
    cat = sql.db_insert(settings.DB_PATH, "categories", {"name": category_name})
    if not no_signal:
        CATEGORIES_UPDATED.fire()
    return cat


def redefine_category(category_id: int, new_name: str, no_signal: bool = False) -> None:
    query = "UPDATE categories SET name=? WHERE id=?"

    with sql.db_cursor(settings.DB_PATH) as cursor:
        sql.cursor_execute(cursor, query, (new_name, category_id))
    if not no_signal:
        CATEGORIES_UPDATED.fire()


def remove_category(category_id: int, no_signal: bool = False) -> None:
    queries = ["DELETE FROM games_categories WHERE category_id=?", "DELETE FROM categories WHERE id=?"]

    for query in queries:
        with sql.db_cursor(settings.DB_PATH) as cursor:
            sql.cursor_execute(cursor, query, (category_id,))

    if not no_signal:
        CATEGORIES_UPDATED.fire()


def add_game_to_category(game_id, category_id):
    """Add a category to a game"""
    return sql.db_insert(settings.DB_PATH, "games_categories", {"game_id": game_id, "category_id": category_id})


def remove_category_from_game(game_id, category_id):
    """Remove a category from a game"""
    query = "DELETE FROM games_categories WHERE category_id=? AND game_id=?"
    with sql.db_cursor(settings.DB_PATH) as cursor:
        sql.cursor_execute(cursor, query, (category_id, game_id))


def remove_unused_categories():
    """Remove all categories that have no games associated with them"""

    delete_orphaned_games = (
        "DELETE FROM games_categories "
        "WHERE NOT EXISTS(SELECT * FROM games WHERE game_id=id) "
        "OR NOT EXISTS(SELECT * FROM categories WHERE category_id=id)"
    )

    with sql.db_cursor(settings.DB_PATH) as cursor:
        sql.cursor_execute(cursor, delete_orphaned_games, ())

    find_orphaned_categories = (
        "SELECT categories.* FROM categories "
        "LEFT JOIN games_categories ON categories.id = games_categories.category_id "
        "WHERE games_categories.category_id IS NULL"
    )

    empty_categories = sql.db_query(settings.DB_PATH, find_orphaned_categories)
    for category in empty_categories:
        if category["name"] == "favorite":
            continue

        delete_orphaned_categories = "DELETE FROM categories WHERE categories.id=?"
        with sql.db_cursor(settings.DB_PATH) as cursor:
            sql.cursor_execute(cursor, delete_orphaned_categories, (category["id"],))
