from typing import List, Optional

import sqlite3
import sadb
import sadb.utilities as utilities
from sadb.configuration import SadbConfig
import os.path
from urllib.parse import urlparse, urlunparse

# Shortened alias functions
tcsl = sadb.to_csl
fcsl = sadb.from_csl

APP_COLUMNS = [
    "id", "name", "primary_src", "src_pkg_name", "icon_url", "author",
    "summary", "description", "categories", "keywords", "mimetypes",
    "license", "pricing", "mobile", "still_rating", "still_rating_notes",
    "homepage", "donate_url", "screenshot_urls", "demo_url", "addons",
    "manual_flags", "manual_url", "manual_instructions", "docs_link",
    "appstream_name",
]
INSTALLED_COLUMNS = APP_COLUMNS[:-1] + ["update_available", "appstream_name"]


# Function to check if the database is in the correct format
def is_valid_sqlite_db(path) -> bool:
    """
    Checks if the SQLite database at the specified path is valid.

    Returns:
        bool: True if the database is valid, False otherwise.
    """
    out = True
    conn = None
    try:
        conn = sqlite3.connect(path)
        cursor = conn.cursor()
        cursor.execute("SELECT 1")
        conn.close()
    except sqlite3.Error:
        out = False
    finally:
        if conn:
            conn.close()
        return out


def remove_duplicate_apps(apps: List[sadb.App]) -> List[sadb.App]:
    unique_apps = {}
    for app in apps:
        if app.src_pkg_name not in unique_apps:
            unique_apps[app.src_pkg_name] = app
    return list(unique_apps.values())


# Read-only version of the database
class ReadableDB:
    """
    A class used to represent a read-only SQLite database.

    ...

    Attributes
    ----------
    conn : sqlite3.Connection
        a SQLite connection object
    c : sqlite3.Cursor
        a SQLite cursor object

    Methods
    -------
    get_app(app_id: str) -> sadb.App:
        Returns the app with the given id from the database.
    column_to_app(column: tuple):
        Converts a SQL query result to an App class instance.
    get_all_apps() -> list:
        Returns all apps from the database.
    get_apps_from_query(query: str) -> list:
        Executes the given SQL query and returns the result as a list of App class instances.
    """
    def __init__(self, config: SadbConfig, init_db: bool = True):
        """
        Constructs a new ReadableDB instance.

        Parameters:
            config (SadbConfig): The configuration for the database.
            init_db (bool): Whether to initialize the database connection. Default is True.
        """
        if init_db:  # used to prevent init of the connection for writable db
            # Use uri workaround to open in read-only mode
            file_uri = urlunparse(urlparse(os.path.abspath(config.db_location))._replace(scheme='file')) + "?mode=ro"
            self.conn = sqlite3.connect(file_uri, uri=True)
            self.c = self.conn.cursor()

    def __enter__(self):
        return self

    def __exit__(self, type, value, traceback):
        self.conn.close()

    def has_column(self, table: str, column: str) -> bool:
        """Return whether *table* contains *column*, including legacy caches."""
        self.c.execute(f"PRAGMA table_info({table})")
        return column in {row[1] for row in self.c.fetchall()}

    def select_columns(self, table: str, columns: List[str]) -> str:
        """Build a compatible projection, falling back to name for AppStream names."""
        has_appstream_name = self.has_column(table, "appstream_name")
        projected = []
        for column in columns:
            if column == "appstream_name":
                if has_appstream_name:
                    projected.append("COALESCE(NULLIF(appstream_name, ''), name) AS appstream_name")
                else:
                    projected.append("name AS appstream_name")
            else:
                projected.append(column)
        return ",".join(projected)

    def get_app(self, app_id: str) -> Optional[sadb.App]:
        """
        Returns the app with the given id from the database.

        Parameters:
            app_id (str): The id of the app.

        Returns:
            sadb.App: The app with the given id.
        """
        self.c.execute(f"SELECT {self.select_columns('apps', APP_COLUMNS)} FROM apps WHERE id=?", (app_id, ))
        app = self.c.fetchone()
        if app is None:
            return None
        return self.column_to_app(app)

    def get_installed_app_from_main_db(self, source: str, package: str) -> Optional[sadb.App]:
        """
        Returns the app with the given id from the database.

        Parameters:
            app_id (str): The id of the app.

        Returns:
            sadb.App: The app with the given id.
        """
        self.c.execute(f"SELECT {self.select_columns('apps', APP_COLUMNS)} FROM apps WHERE primary_src=? AND src_pkg_name=?", (source, package))
        app = self.c.fetchone()
        if app is None:
            return None
        return sadb.InstalledApp.from_app(self.column_to_app(app))

    def get_installed_apps(self) -> List[sadb.InstalledApp]:
        """
        Returns:
            List[sadb.InstalledApp]: A list of installed apps from the database.
        """
        self.c.execute(f"SELECT {self.select_columns('installed', INSTALLED_COLUMNS)} FROM installed")
        apps = []
        apps_columns = self.c.fetchall()

        for app in apps_columns:
            apps.append(self.column_to_installed_app(app))
        return apps

    def get_installed_app(self, source, package) -> sadb.App:
        self.c.execute(f"SELECT {self.select_columns('installed', INSTALLED_COLUMNS)} FROM installed WHERE primary_src=? AND src_pkg_name=?", (source, package))
        app = self.c.fetchone()
        if app is None:
            return None
        return self.column_to_installed_app(app)

    def get_installed_app_by_id(self, app_id: str) -> Optional[sadb.InstalledApp]:
        self.c.execute(f"SELECT {self.select_columns('installed', INSTALLED_COLUMNS)} FROM installed WHERE id=?", (app_id,))
        app = self.c.fetchone()
        if app is None:
            return None
        return self.column_to_installed_app(app)

    def get_app_updates(self) -> List[sadb.InstalledApp]:
        """
        Returns:
            List[sadb.InstalledApp]: A list of installed apps from the database.
        """
        self.c.execute(f"SELECT {self.select_columns('installed', INSTALLED_COLUMNS)} FROM installed WHERE update_available = 1")
        apps = []
        apps_columns = self.c.fetchall()

        for app in apps_columns:
            apps.append(self.column_to_installed_app(app))
        return apps


    @staticmethod
    def column_to_app(column: tuple):
        """
        Converts a SQL query result to an App class instance.

        Parameters:
            column (tuple): The SQL query result.

        Returns:
            sadb.App: The App class instance.
        """
        assert len(column) >= 21
        return sadb.App(
            column[0], column[1], column[2], column[3], column[4], column[5],
            column[6], column[7], fcsl(column[8]), fcsl(column[9]), fcsl(column[10]),
            column[11], sadb.Pricing(column[12]), sadb.MobileType((column[13])),
            sadb.StillRating(column[14]), column[15], column[16], column[17], fcsl(column[18]),
            column[19], fcsl(column[20]), fcsl(column[21]) if len(column) > 21 else None,
            column[22] if len(column) > 22 else None, column[23] if len(column) > 23 else None,
            column[24] if len(column) > 24 else None, column[25] if len(column) > 25 else column[1]
        )

    @staticmethod
    def column_to_installed_app(column: tuple):
        """
        Converts a SQL query result to an InstalledApp class instance.

        Parameters:
            column (tuple): The SQL query result.

        Returns:
            sadb.App: The App class instance.
        """
        assert len(column) >= 22
        update_available = column[25] if len(column) > 25 else column[21]
        appstream_name = column[26] if len(column) > 26 else column[1]
        return sadb.InstalledApp(
            update_available, column[0], column[1], column[2], column[3], column[4], column[5],
            column[6], column[7], fcsl(column[8]), fcsl(column[9]), fcsl(column[10]),
            column[11], sadb.Pricing(column[12]), sadb.MobileType((column[13])),
            sadb.StillRating(column[14]), column[15], column[16], column[17], fcsl(column[18]),
            column[19], fcsl(column[20]), fcsl(column[21]) if len(column) > 22 else None,
            column[22] if len(column) > 23 else None, column[23] if len(column) > 24 else None,
            column[24] if len(column) > 25 else None, appstream_name
        )

    def get_all_apps(self) -> list:
        """
        Returns all apps from the database.

        Returns:
            list: The list of all apps.
        """
        self.c.execute(f"SELECT {self.select_columns('apps', APP_COLUMNS)} FROM apps")
        return [self.column_to_app(app) for app in self.c.fetchall()]

    def get_apps_from_query(self, query: str) -> list:
        """
        Executes the given SQL query and returns the result as a list of App class instances.

        Parameters:
            query (str): The SQL query.

        Returns:
            list: The list of App class instances.
        """
        self.c.execute(query)
        return [self.column_to_app(app) for app in self.c.fetchall()]




class WritableDB(ReadableDB):
    """
    A class used to represent a writable SQLite database.

    ...

    Attributes
    ----------
    conn : sqlite3.Connection
        a SQLite connection object
    c : sqlite3.Cursor
        a SQLite cursor object

    Methods
    -------
    create_db():
        Creates the database if it does not exist.
    add_app(app: sadb.App) -> None:
        Adds the given app to the database.
    add_apps(apps: List[sadb.App]) -> None:
        Adds the given list of apps to the database.
    clear_db() -> None:
        Deletes all apps from the database.
    """
    def __init__(self, config: SadbConfig):
        """
        Constructs a new WritableDB instance.

        Parameters:
            config (SadbConfig): The configuration for the database.
        """
        new_db = not os.path.exists(config.db_location)
        # Create folders for database if they don't exist
        if new_db and not os.path.exists(os.path.dirname(config.db_location)):
            os.makedirs(os.path.dirname(config.db_location), exist_ok=True)
            if utilities.is_sudo_root():
                utilities.fix_perms(os.path.dirname(config.db_location))

        self.conn = sqlite3.connect(config.db_location)
        self.c = self.conn.cursor()
        if new_db:
            self.create_db()
            if utilities.is_sudo_root():
                utilities.fix_perms(config.db_location)
        super().__init__(config, init_db=False)
        if not new_db:
            self._ensure_manual_install_columns()
            self.conn.commit()

    def create_db(self):
        """
        Creates the database if it does not exist.
        """
        self.c.execute('''CREATE TABLE IF NOT EXISTS apps
            (id text, name text, primary_src text, src_pkg_name text, icon_url text, 
            author text, summary text, description text, categories text, keywords text, 
            mimetypes text, license text, pricing int, mobile int, still_rating int, 
            still_rating_notes text, homepage text, donate_url text, screenshot_urls text, 
            demo_url text, addons text, manual_flags text, manual_url text,
            manual_instructions text, docs_link text, appstream_name text)''')
        self.c.execute('''CREATE TABLE IF NOT EXISTS installed
            (id text, name text, primary_src text, src_pkg_name text, icon_url text, 
            author text, summary text, description text, categories text, keywords text, 
            mimetypes text, license text, pricing int, mobile int, still_rating int, 
            still_rating_notes text, homepage text, donate_url text, screenshot_urls text, 
            demo_url text, addons text, manual_flags text, manual_url text,
            manual_instructions text, docs_link text, update_available int,
            appstream_name text)''')
        self._ensure_manual_install_columns()
        # Add FTS5 table for apps
        self.c.execute('''CREATE VIRTUAL TABLE IF NOT EXISTS apps_fts USING fts5(
            id, name, appstream_name, author, summary, description, keywords, content='apps', content_rowid='rowid'
        )''')

    def _ensure_manual_install_columns(self):
        """Add optional external install metadata columns to older databases."""
        new_columns = {
            "manual_flags": "text",
            "manual_url": "text",
            "manual_instructions": "text",
            "docs_link": "text",
            "appstream_name": "text",
        }
        for table in ("apps", "installed"):
            self.c.execute(f"PRAGMA table_info({table})")
            existing = {row[1] for row in self.c.fetchall()}
            for column, column_type in new_columns.items():
                if column not in existing:
                    self.c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}")
            self.c.execute(
                f"UPDATE {table} SET appstream_name = name "
                "WHERE appstream_name IS NULL OR appstream_name = ''"
            )

    def _ensure_fts_table(self):
        """
        Ensures the FTS5 table for apps exists. This is for cross-compatibility with older databases.
        """
        self.c.execute(
            """CREATE VIRTUAL TABLE IF NOT EXISTS apps_fts USING fts5(
                id, name, appstream_name, author, summary, description, keywords, content='apps', content_rowid='rowid'
            )"""
        )
        self.c.execute("PRAGMA table_info(apps_fts)")
        existing = {row[1] for row in self.c.fetchall()}
        if "appstream_name" not in existing:
            self.c.execute("DROP TABLE IF EXISTS apps_fts")
            self.c.execute(
                """CREATE VIRTUAL TABLE apps_fts USING fts5(
                    id, name, appstream_name, author, summary, description, keywords,
                    content='apps', content_rowid='rowid'
                )"""
            )

    def clear_db(self) -> None:
        """
        Deletes all apps from the database.
        Ensures the FTS5 table exists before attempting to delete from it (for cross-compatibility).
        """
        self.c.execute("DELETE FROM apps")
        # Ensure FTS table exists before deleting from it (cross-compatibility for older DBs)
        self._ensure_fts_table()
        self.c.execute("DELETE FROM apps_fts")
        #  self.conn.commit()  REMOVE COMMIT INCASE FUTURE OPERATION IS UNSUCCESSFUL

    def clear_installed_apps(self) -> None:
        """
        Clears the installed app daatbaase
        """
        self.c.execute("DELETE FROM installed")
        #  self.conn.commit()  REMOVE COMMIT INCASE FUTURE OPERATION IS UNSUCCESSFUL

    def add_installed_app(self, app: sadb.InstalledApp):
        """
        Add app to the installed database
        """
        """
        Adds the given list of apps to the database.

        Parameters:
            apps (list): The list of apps to add.
        """
        # Prevent apps with the same package name
        self.c.execute("SELECT * FROM installed WHERE src_pkg_name=?", (app.src_pkg_name,))
        if self.c.fetchone() is not None:
            raise ValueError(f"An app with src_pkg_name {app.src_pkg_name} already exists.")
        self.c.execute(
            f"INSERT OR REPLACE INTO installed ({','.join(INSTALLED_COLUMNS)}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
            (
                app.app_id, app.name, app.primary_src, app.src_pkg_name, app.icon_url,
                app.author, app.summary, app.description, tcsl(app.categories),
                tcsl(app.keywords), tcsl(app.mimetypes), app.app_license, app.pricing.value,
                app.mobile.value, app.still_rating.value, app.still_rating_notes, app.homepage,
                app.donate_url, tcsl(app.screenshot_urls), app.demo_url, tcsl(app.addons),
                tcsl(app.manual_flags), app.manual_url, app.manual_instructions,
                app.docs_link, app.update_available, app.appstream_name
            )
        )
        self.conn.commit()

    def add_installed_apps(self, apps: List[sadb.InstalledApp]):
        """
        Add app to the installed database.

        Parameters:
            app (sadb.InstalledApp): The app to add.
        """
        self.c.execute("SELECT primary_src, src_pkg_name FROM installed")
    
        rows = self.c.fetchall()

        if len(rows) > 0:
            existing_pkgs = rows
        else:
            existing_pkgs = []
        
        apps = remove_duplicate_apps(apps)
        apps_data = [
            (
                app.app_id, app.name, app.primary_src, app.src_pkg_name, app.icon_url,
                app.author, app.summary, app.description, tcsl(app.categories),
                tcsl(app.keywords), tcsl(app.mimetypes), app.app_license, app.pricing.value,
                app.mobile.value, app.still_rating.value, app.still_rating_notes, app.homepage,
                app.donate_url, tcsl(app.screenshot_urls), app.demo_url, tcsl(app.addons),
                tcsl(app.manual_flags), app.manual_url, app.manual_instructions,
                app.docs_link, app.update_available, app.appstream_name
            ) for app in apps if (app.primary_src, app.src_pkg_name) not in existing_pkgs
        ]
        self.c.executemany(
            f"INSERT OR REPLACE INTO installed ({','.join(INSTALLED_COLUMNS)}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
            apps_data
        )
        self.conn.commit()

    def add_apps(self, apps: List[sadb.App]) -> None:
        """
        Adds the given list of apps to the database.

        Parameters:
            apps (list): The list of apps to add.
        """
        # Ensure FTS table exists for cross-compatibility
        self._ensure_fts_table()
        # Removing duplicate package names
        self.c.execute("SELECT src_pkg_name FROM apps")
        existing_pkgs = [row[0] for row in self.c.fetchall()]
        apps = remove_duplicate_apps(apps)
        apps_data = [
            (
                app.app_id, app.name, app.primary_src, app.src_pkg_name, app.icon_url,
                app.author, app.summary, app.description, tcsl(app.categories),
                tcsl(app.keywords), tcsl(app.mimetypes), app.app_license, app.pricing.value,
                app.mobile.value, app.still_rating.value, app.still_rating_notes, app.homepage,
                app.donate_url, tcsl(app.screenshot_urls), app.demo_url, tcsl(app.addons),
                tcsl(app.manual_flags), app.manual_url, app.manual_instructions,
                app.docs_link, app.appstream_name
            ) for app in apps if app.src_pkg_name not in existing_pkgs
        ]
        self.c.executemany(
            f"INSERT OR REPLACE INTO apps ({','.join(APP_COLUMNS)}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
            apps_data
        )
        # Sync FTS5 table for batch insert
        fts_data = [
            (app.app_id, app.app_id, app.name, app.appstream_name, app.author, app.summary, app.description, tcsl(app.keywords))
            for app in apps if app.src_pkg_name not in existing_pkgs
        ]
        self.c.executemany(
            "INSERT OR REPLACE INTO apps_fts (rowid, id, name, appstream_name, author, summary, description, keywords) VALUES ((SELECT rowid FROM apps WHERE id=?),?,?,?,?,?,?,?)",
            fts_data
        )
        self.conn.commit()


def get_readable_db() -> ReadableDB:
    return ReadableDB(SadbConfig())


# Functions for updating and managing the database
def update_db(config: SadbConfig = None, start_step: int = 0):
    """Updates the database with the latest yaml data."""
    if config is None:
        config = SadbConfig()
    
    # Import here to avoid circular imports
    import sadb.appstream as appstream
    import sadb.utilities as util
    import sadb.yaml_parse as yaml_parse
    from sadb.appstream_metadata import complement_flatpak_apps
    from sadb.source.flatpak import FlatpakType
    from urllib.parse import urljoin

    if config.verbose:
        print(f"\nUpdating Flatpak AppStream metadata ({start_step + 1}/{start_step + 3}):")
    FlatpakType.update_appstream(verbose=config.verbose)

    if config.verbose:
        print(f"\nDownloading yaml database ({start_step + 2}/{start_step + 3}):")
    db_yaml = util.download_yaml(urljoin(config.repo_url, "repo.yaml"), verbose=config.verbose)
    apps = yaml_parse.get_apps_from_yaml(db_yaml)
    # Add apps to database
    if config.verbose:
        print(f"\n(Re)generating database ({start_step + 3}/{start_step + 3})")
    apps = appstream.enrich_apps(apps)
    # Only add stillCenter-safe placeholders after every real AppStream source
    # has had a chance to populate the compact catalog records.  Doing this
    # before enrich_apps() made placeholders such as "Unknown Author" look
    # like curated values, preventing the richer loader from replacing them.
    complement_flatpak_apps(apps)
    with WritableDB(config) as db:
        db.clear_db()
        db.add_apps(apps)


def get_db_location(config: SadbConfig = None) -> str:
    """Returns the location of the database."""
    if config is None:
        config = SadbConfig()
    return config.db_location


def update_installed(config: SadbConfig = None):
    """Updates the installed apps database."""
    if config is None:
        config = SadbConfig()
    
    # Import here to avoid circular imports
    import sadb.source.manager as source_man
    
    with WritableDB(config) as db:
        db.clear_installed_apps()
        for source in source_man.sources.values():
            source.add_installed_to_db(db)
