from typing import List
import os
import re

import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gio, GObject

import sadb.database

STORE = {}
INSTALLED_STORE = {}

COLUMN_POINTS = {
    "name": 10,
    "src_pkg_name": 10,
    "author": 10,
    "summary": 5,
    "description": 3,
    "keywords": 5
}


def _appstream_name_sql(db, table="apps", qualifier=""):
    """Return a display-name SQL expression compatible with older saDB caches."""
    prefix = f"{qualifier}." if qualifier else ""
    db.c.execute(f"PRAGMA table_info({table})")
    columns = {row[1] for row in db.c.fetchall()}
    if "appstream_name" in columns:
        return f"COALESCE(NULLIF({prefix}appstream_name, ''), {prefix}name)"
    return f"{prefix}name"

class AppItem(GObject.Object):
    __gtype_name__ = "AppItem"

    def __init__(self, app_id, name, author, icon):
        super().__init__()
        self._app_id = app_id
        self._name = name
        self._author = author or "Unknown Author"
        self._author_visible = True
        self._icon = icon
        self._installed = False
        self._update = False

    @classmethod
    def new_installed(cls, app_id, name, author, icon, installed, update):
        app = cls(app_id, name, author, icon)
        app._installed = installed
        app._update = update
        return app

    @GObject.Property(type=str)
    def app_id(self):
        return self._app_id

    @app_id.setter
    def app_id(self, value):
        self._app_id = value

    @GObject.Property(type=str)
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        self._name = value

    @GObject.Property(type=str)
    def author(self):
        return self._author

    @author.setter
    def author(self, value):
        self._author = value or "Unknown Author"
        self.author_visible = True

    @GObject.Property(type=bool, default=True)
    def author_visible(self):
        return self._author_visible

    @author_visible.setter
    def author_visible(self, value):
        self._author_visible = value

    @GObject.Property(type=str)
    def icon(self):
        return self._icon

    @icon.setter
    def icon(self, value):
        self._icon = value

    @GObject.Property(type=bool, default=False)
    def installed(self):
        return self._installed

    @installed.setter
    def installed(self, value):
        self._installed = value

    @GObject.Property(type=bool, default=False)
    def update(self):
        return self._update

    @update.setter
    def update(self, value):
        self._update = value


def refresh_app_store():
    global STORE
    STORE = {"all": Gio.ListStore()}
    
    # Clear search cache when app store is refreshed
    if hasattr(search_algorithm, '_cache'):
        search_algorithm._cache.clear()

    db = sadb.database.get_readable_db()

    appstream_name = _appstream_name_sql(db)
    db.c.execute(
        f"SELECT id, name, {appstream_name} AS appstream_name, author, icon_url, categories, keywords FROM apps"
    )
    apps_columns = db.c.fetchall()


    for app in apps_columns:
        display_name = app[2] or app[1]
        categories_raw = app[5] or ""
        keywords_raw = app[6] or ""
        categories = [category.strip() for category in categories_raw.split(",") if category.strip()]
        keywords = [keyword.strip() for keyword in keywords_raw.split(",") if keyword.strip()]

        app_item = AppItem(app[0], display_name, app[3], app[4])

        for category in categories:
            add_to_category(category, app_item)

            # Used to separate audio and video tools that may have categories such as
            # "Audio", "AudioVideo", "Editor", "Player"
            if "Audio" in categories:
                add_to_category("Audio" + category, app_item)
            if "Video" in categories:
                add_to_category("Video" + category, app_item)

        for keyword in keywords:
            add_to_category("keyword-" + keyword, app_item)
        STORE["all"].append(app_item)


def add_to_category(category, app_item):#
    if category not in STORE.keys():
        STORE[category] = Gio.ListStore()
    STORE[category].append(app_item)


def has_swai_desktop_file(app_id: str) -> bool:
    desktop_filename = f"{app_id}.desktop"
    desktop_paths = (
        os.path.join("/usr/share/applications", desktop_filename),
        os.path.join("/usr/local/share/applications", desktop_filename),
    )
    return any(os.path.exists(path) for path in desktop_paths)


def refresh_installed_store():
    global INSTALLED_STORE
    INSTALLED_STORE = {"installed": Gio.ListStore(), "update": Gio.ListStore(), "no_update": Gio.ListStore()}

    db = sadb.database.get_readable_db()
    appstream_name = _appstream_name_sql(db, "installed")
    db.c.execute(
        f"SELECT id, name, {appstream_name} AS appstream_name, author, icon_url, categories, keywords, update_available FROM installed"
    )
    apps_columns = db.c.fetchall()

    for app in apps_columns:
        app_id = app[0]
        # SWAI entries require a desktop file. Skip invalid entries so they
        # don't show up as installed when their desktop file is missing.
        if app_id.startswith("swai.") and not has_swai_desktop_file(app_id):
            continue

        display_name = app[2] or app[1]
        app_item = AppItem.new_installed(app[0], display_name, app[3], app[4], True, app[7])

        INSTALLED_STORE["installed"].append(app_item)
        if app[7]:
            INSTALLED_STORE["update"].append(app_item)
        else:
            INSTALLED_STORE["no_update"].append(app_item)


def _search_tokens(query: str) -> list[str]:
    return re.findall(r"[a-z0-9]+", query.lower())


def _contains_all_tokens(value: str, tokens: list[str]) -> bool:
    value = (value or "").lower()
    return all(token in value for token in tokens)


def _word_prefix_match(value: str, query: str) -> bool:
    return any(word.startswith(query) for word in re.findall(r"[a-z0-9]+", (value or "").lower()))


def _search_score(row: tuple, query: str, tokens: list[str], fts_rank: float) -> tuple:
    _app_id, name, appstream_name, author, _icon, src_pkg_name, summary, description, keywords = row
    query = query.lower()
    display_name = appstream_name or name
    searchable = {
        "name": (name or "").lower(),
        "appstream_name": (appstream_name or "").lower(),
        "display_name": (display_name or "").lower(),
        "author": (author or "").lower(),
        "src_pkg_name": (src_pkg_name or "").lower(),
        "summary": (summary or "").lower(),
        "description": (description or "").lower(),
        "keywords": (keywords or "").lower(),
    }

    if searchable["display_name"] == query or searchable["name"] == query:
        rank = 0
    elif searchable["display_name"].startswith(query) or searchable["name"].startswith(query):
        rank = 5
    elif _word_prefix_match(searchable["display_name"], query) or _word_prefix_match(searchable["name"], query):
        rank = 8
    elif query in searchable["display_name"] or query in searchable["name"]:
        rank = 10
    elif searchable["src_pkg_name"] == query:
        rank = 12
    elif query in searchable["src_pkg_name"]:
        rank = 15
    elif _contains_all_tokens(searchable["keywords"], tokens):
        rank = 25
    elif searchable["author"] == query:
        rank = 30
    elif query in searchable["author"]:
        rank = 35
    elif _contains_all_tokens(searchable["summary"], tokens):
        rank = 45
    elif _contains_all_tokens(searchable["description"], tokens):
        rank = 60
    else:
        rank = 80

    return (rank, fts_rank, searchable["display_name"])


def search_rows(query: str) -> list[tuple[str, str, str, str]]:
    db = sadb.database.get_readable_db()
    query = query.strip().lower()
    tokens = _search_tokens(query)
    if not tokens:
        return []

    rows_by_id = {}
    scores_by_id = {}
    like_query = f"%{query}%"
    appstream_name = _appstream_name_sql(db)

    direct_sql = """
        SELECT id, name, {appstream_name} AS appstream_name, author, icon_url, src_pkg_name, summary, description, keywords
        FROM apps
        WHERE lower(name) LIKE ?
            OR lower({appstream_name}) LIKE ?
            OR lower(src_pkg_name) LIKE ?
            OR lower(author) LIKE ?
            OR lower(keywords) LIKE ?
            OR lower(summary) LIKE ?
        LIMIT 100
    """.format(appstream_name=appstream_name)

    for row in db.c.execute(direct_sql, (like_query, like_query, like_query, like_query, like_query, like_query)):
        rows_by_id[row[0]] = row
        scores_by_id[row[0]] = _search_score(row, query, tokens, 0.0)

    fts_query = " ".join(f"{token}*" for token in tokens)
    qualified_appstream_name = _appstream_name_sql(db, qualifier="apps")
    fts_sql = f"""
        SELECT apps.id, apps.name, {qualified_appstream_name} AS appstream_name, apps.author, apps.icon_url, apps.src_pkg_name,
            apps.summary, apps.description, apps.keywords,
            bm25(apps_fts, 3.0, 8.0, 8.0, 2.0, 1.0, 1.0, 4.0) AS fts_rank
        FROM apps_fts
        JOIN apps ON apps.rowid = apps_fts.rowid  
        WHERE apps_fts MATCH ?
        ORDER BY fts_rank
        LIMIT 100
    """

    for row in db.c.execute(fts_sql, (fts_query,)):
        app_row = row[:-1]
        fts_rank = row[-1]
        rows_by_id[app_row[0]] = app_row
        score = _search_score(app_row, query, tokens, fts_rank)
        if app_row[0] not in scores_by_id or score < scores_by_id[app_row[0]]:
            scores_by_id[app_row[0]] = score

    sorted_rows = sorted(rows_by_id.values(), key=lambda row: scores_by_id[row[0]])
    return [(row[0], row[2] or row[1], row[3], row[4]) for row in sorted_rows]


def rows_to_store(results: list[tuple[str, str, str, str]]) -> Gio.ListStore:
    list_store = Gio.ListStore()
    for row in results:
        list_store.append(AppItem(row[0], row[1], row[2], row[3]))

    return list_store


def search_algorithm(query: str) -> Gio.ListStore:
    return rows_to_store(search_rows(query))
