"""Database utilities."""

from __future__ import annotations

import contextlib
import enum
import json
import sqlite3
import threading
import time
from collections import defaultdict
from enum import StrEnum, auto
from typing import TYPE_CHECKING, Any, assert_never, cast

import attrs
from more_itertools import batched

from usdb_syncer import SongId, SyncMetaId, errors
from usdb_syncer.logger import logger

from .sql import Sql

if TYPE_CHECKING:
    from collections.abc import Generator, Iterable, Iterator
    from pathlib import Path


SCHEMA_VERSION = 9

# https://www.sqlite.org/limits.html
_SQL_VARIABLES_LIMIT = 32766

# 1000 is the maximum expression tree depth. Since
# an sqlite update could change this without us
# noticing quickly, we're leaving some space.
# Performance impact is negligible.
_SQL_SMALLER_THAN_EXPRESSION_TREE = 980

_STATUS_COLUMN = """coalesce(
    session_usdb_song.status,
    CASE
        sync_meta.usdb_mtime = usdb_song.usdb_mtime
        WHEN true THEN 1
        WHEN false THEN 2
        ELSE 0
    END
)"""


class _LocalConnection(threading.local):
    """A thread-local database connection."""

    connection: sqlite3.Connection | None = None


class _DbState:
    """Singleton for managing the global database connection."""

    _local: _LocalConnection = _LocalConnection()
    in_transaction: bool = False
    trace_sql: bool = False

    @classmethod
    def connect(cls, db_path: Path | str) -> None:
        if cls._local.connection:
            raise errors.AlreadyConnectedError()
        cls._local.connection = sqlite3.connect(
            db_path, check_same_thread=False, isolation_level=None, timeout=60
        )
        thread = threading.current_thread().name
        logger.debug(f"Connected to database at '{db_path}' on thread {thread}.")
        if cls.trace_sql:
            cls._local.connection.set_trace_callback(logger.debug)
        _validate_schema(cls._local.connection)

    @classmethod
    def connection(cls) -> sqlite3.Connection:
        if cls._local.connection is None:
            raise errors.NotConnectedError()
        return cls._local.connection

    @classmethod
    def close(cls) -> None:
        if _DbState._local.connection is not None:
            _DbState._local.connection.close()
            _DbState._local.connection = None
            thread = threading.current_thread().name
            logger.debug(f"Closed database connection on thread {thread}.")


@contextlib.contextmanager
def transaction() -> Generator[None, None, None]:
    try:
        _DbState.connection().execute("BEGIN IMMEDIATE")
        _DbState.in_transaction = True
        yield None
    except Exception:
        _DbState.connection().rollback()
        _DbState.in_transaction = False
        raise
    _DbState.connection().commit()
    _DbState.in_transaction = False


def _validate_schema(connection: sqlite3.Connection) -> None:
    meta_table = connection.execute(
        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'"
    ).fetchone()
    if meta_table is None:
        version = 0
    else:
        row = connection.execute("SELECT version FROM meta WHERE id = 1").fetchone()
        if not row or row[0] > SCHEMA_VERSION:
            raise errors.UnknownSchemaError
        version = row[0]
    for ver in range(version + 1, SCHEMA_VERSION + 1):
        connection.executescript(Sql.migration_text(ver))
        logger.debug(f"Database migrated to version {ver}.")
    if version < SCHEMA_VERSION:
        connection.execute(
            "INSERT INTO meta (id, version, ctime) VALUES (1, :version, :ctime) "
            "ON CONFLICT (id) DO UPDATE SET version = :version",
            {"version": SCHEMA_VERSION, "ctime": int(time.time() * 1_000_000)},
        )
    connection.executescript(Sql.SETUP_SESSION_SCRIPT.text_uncached())


def connect(db_path: Path | str) -> None:
    _DbState.connect(db_path)


def close() -> None:
    _DbState.close()


@contextlib.contextmanager
def managed_connection(db_path: Path | str) -> Generator[None, None, None]:
    try:
        _DbState.connect(db_path)
        yield None
    finally:
        _DbState.close()


def set_trace_sql(trace_sql: bool) -> None:
    """Set SQL logging for future connections."""
    _DbState.trace_sql = trace_sql


class JobStatus(StrEnum):
    """Status of a download job."""

    SKIPPED_DISABLED = auto()
    SKIPPED_UNAVAILABLE = auto()
    FAILURE = auto()
    FAILURE_EXISTING = auto()
    FALLBACK = auto()
    SUCCESS_UNCHANGED = auto()
    SUCCESS = auto()


class DownloadStatus(enum.IntEnum):
    """Status of song in download queue."""

    # integers must agree with select_usdb_song.sql
    NONE = 0
    SYNCHRONIZED = enum.auto()
    OUTDATED = enum.auto()
    PENDING = enum.auto()
    DOWNLOADING = enum.auto()
    FAILED = enum.auto()

    def __str__(self) -> str:
        match self:
            case DownloadStatus.NONE:
                return ""
            case DownloadStatus.SYNCHRONIZED:
                return "Synchronized"
            case DownloadStatus.OUTDATED:
                return "Outdated"
            case DownloadStatus.PENDING:
                return "Pending"
            case DownloadStatus.DOWNLOADING:
                return "Downloading"
            case DownloadStatus.FAILED:
                return "Failed"
            case _ as unreachable:
                assert_never(unreachable)

    def can_be_downloaded(self) -> bool:
        return self not in (DownloadStatus.PENDING, DownloadStatus.DOWNLOADING)

    def can_be_aborted(self) -> bool:
        return self in (DownloadStatus.PENDING, DownloadStatus.DOWNLOADING)

    def for_db(self) -> DownloadStatus | None:
        match self:
            case (
                DownloadStatus.NONE
                | DownloadStatus.SYNCHRONIZED
                | DownloadStatus.OUTDATED
            ):
                return None
            case (
                DownloadStatus.PENDING
                | DownloadStatus.DOWNLOADING
                | DownloadStatus.FAILED
            ):
                return self
            case _ as unreachable:
                assert_never(unreachable)


class SongOrder(enum.Enum):
    """Attributes songs can be sorted by."""

    NONE = 0
    SAMPLE_URL = enum.auto()
    SONG_ID = enum.auto()
    ARTIST = enum.auto()
    TITLE = enum.auto()
    EDITION = enum.auto()
    LANGUAGE = enum.auto()
    GOLDEN_NOTES = enum.auto()
    RATING = enum.auto()
    VIEWS = enum.auto()
    YEAR = enum.auto()
    GENRE = enum.auto()
    CREATOR = enum.auto()
    TAGS = enum.auto()
    PINNED = enum.auto()
    TXT = enum.auto()
    AUDIO = enum.auto()
    VIDEO = enum.auto()
    COVER = enum.auto()
    BACKGROUND = enum.auto()
    LAST_CHANGE = enum.auto()
    STATUS = enum.auto()

    def sql(self) -> str | None:  # noqa: C901
        match self:
            case SongOrder.NONE:
                return None
            case SongOrder.SAMPLE_URL:
                return (
                    "CASE WHEN session_usdb_song.is_playing = true THEN 0"
                    " WHEN audio.sync_meta_id IS NOT NULL THEN 1"
                    " WHEN usdb_song.sample_url != '' THEN 2 ELSE 3 END"
                )
            case SongOrder.SONG_ID:
                return "usdb_song.song_id"
            case SongOrder.ARTIST:
                return "usdb_song.artist"
            case SongOrder.TITLE:
                return "usdb_song.title"
            case SongOrder.EDITION:
                return "usdb_song.edition"
            case SongOrder.LANGUAGE:
                return "usdb_song.language"
            case SongOrder.GOLDEN_NOTES:
                return "usdb_song.golden_notes"
            case SongOrder.RATING:
                return "usdb_song.rating"
            case SongOrder.VIEWS:
                return "usdb_song.views"
            case SongOrder.YEAR:
                return "usdb_song.year"
            case SongOrder.GENRE:
                return "usdb_song.genre"
            case SongOrder.CREATOR:
                return "usdb_song.creator"
            case SongOrder.TAGS:
                return "usdb_song.tags"
            case SongOrder.PINNED:
                return "sync_meta.pinned"
            case SongOrder.TXT:
                return self.order("txt")
            case SongOrder.AUDIO:
                return self.order("audio")
            case SongOrder.VIDEO:
                return self.order("video")
            case SongOrder.COVER:
                return self.order("cover")
            case SongOrder.BACKGROUND:
                return self.order("background")
            case SongOrder.LAST_CHANGE:
                return "usdb_song.usdb_mtime"
            case SongOrder.STATUS:
                return _STATUS_COLUMN

    def order(self, alias: str) -> str:
        """Return CASE SQL expression for resource_file status sorting."""
        statuses = [
            JobStatus.SKIPPED_DISABLED,
            JobStatus.SKIPPED_UNAVAILABLE,
            JobStatus.FAILURE,
            JobStatus.FAILURE_EXISTING,
            JobStatus.FALLBACK,
            JobStatus.SUCCESS_UNCHANGED,
            JobStatus.SUCCESS,
        ]

        when_clauses = [
            f"WHEN {alias}.status = '{status}' THEN {i + 1}"
            for i, status in enumerate(statuses)
        ]

        return (
            "CASE "
            f"WHEN {alias}.sync_meta_id IS NULL THEN 0 "
            f"{' '.join(when_clauses)} "
            f"ELSE {len(statuses) + 1} "
            "END"
        )


@attrs.define
class SearchBuilder:
    """Helper for building a where clause to find songs."""

    order: SongOrder = SongOrder.NONE
    descending: bool = False
    text: str = ""
    artists: list[str] = attrs.field(factory=list)
    titles: list[str] = attrs.field(factory=list)
    editions: list[str] = attrs.field(factory=list)
    ratings: list[float] = attrs.field(factory=list)
    statuses: list[DownloadStatus] = attrs.field(factory=list)
    languages: list[str] = attrs.field(factory=list)
    views: list[tuple[int, int | None]] = attrs.field(factory=list)
    years: list[int] = attrs.field(factory=list)
    genres: list[str] = attrs.field(factory=list)
    creators: list[str] = attrs.field(factory=list)
    custom_data: defaultdict[str, list[str]] = attrs.field(
        factory=lambda: defaultdict(list)
    )
    golden_notes: bool | None = None

    def filters(self) -> Iterator[str]:
        if _fts5_phrases(self.text):
            yield (
                "usdb_song.song_id IN (SELECT rowid FROM fts_usdb_song WHERE"
                " fts_usdb_song MATCH ?)"
            )
        for vals, col in (
            (self.artists, "usdb_song.artist"),
            (self.titles, "usdb_song.title"),
            (self.editions, "usdb_song.edition"),
            (self.ratings, "usdb_song.rating"),
            (self.years, "usdb_song.year"),
            (self.statuses, _STATUS_COLUMN),
        ):
            if vals:
                yield _in_values_clause(col, cast("list", vals))
        if self.languages:
            yield (
                "usdb_song.song_id IN (SELECT song_id FROM usdb_song_language WHERE"
                f" {_in_values_clause('language', self.languages)})"
            )
        if self.genres:
            yield (
                "usdb_song.song_id IN (SELECT song_id FROM usdb_song_genre WHERE"
                f" {_in_values_clause('genre', self.genres)})"
            )
        if self.creators:
            yield (
                "usdb_song.song_id IN (SELECT song_id FROM usdb_song_creator WHERE"
                f" {_in_values_clause('creator', self.creators)})"
            )
        if self.custom_data:
            yield (
                "sync_meta.sync_meta_id IN (SELECT sync_meta_id FROM custom_meta_data "
                f"WHERE {_custom_data_clause(self.custom_data)})"
            )
        if self.views:
            yield _in_ranges_clause("usdb_song.views", self.views)
        if self.golden_notes is not None:
            yield "usdb_song.golden_notes = ?"

    def _where_clause(self) -> str:
        where = " AND ".join(self.filters())
        return f" WHERE {where}" if where else ""

    def _order_by_clause(self) -> str:
        if sql := self.order.sql():
            return f" ORDER BY {sql} {'DESC' if self.descending else 'ASC'}"
        return ""

    def parameters(self) -> Iterator[str | float | int | bool]:
        if text := _fts5_phrases(self.text):
            yield text
        yield from self.artists
        yield from self.titles
        yield from self.editions
        yield from self.ratings
        yield from self.years
        yield from self.statuses
        yield from self.languages
        yield from self.genres
        yield from self.creators
        for key, values in self.custom_data.items():
            yield key
            yield from values
        for min_views, max_views in self.views:
            yield min_views
            if max_views is not None:
                yield max_views
        if self.golden_notes is not None:
            yield self.golden_notes

    def statement(self) -> str:
        select_from = Sql.SELECT_SONG_ID.text()
        where = self._where_clause()
        order_by = self._order_by_clause()
        return f"{select_from}{where}{order_by}"

    def to_json(self) -> str:
        return json.dumps(self, cls=_SearchEncoder)

    @classmethod
    def from_json(cls, json_str: str) -> SearchBuilder | None:
        fields = attrs.fields(cls)
        try:
            dct = json.loads(json_str)
            dct[fields.order.name] = SongOrder(dct[fields.order.name])
            dct[fields.statuses.name] = [
                DownloadStatus(s) for s in dct[fields.statuses.name]
            ]
            dct[fields.views.name] = [tuple(item) for item in dct[fields.views.name]]
            return cls(**dct)
        except (
            json.decoder.JSONDecodeError,
            UnicodeDecodeError,
            TypeError,
            KeyError,
            ValueError,
        ):
            logger.exception(None)
        return None


class _SearchEncoder(json.JSONEncoder):
    """Custom encoder for a search."""

    def default(self, o: Any) -> Any:
        if isinstance(o, SearchBuilder):
            return attrs.asdict(o, recurse=False)
        if isinstance(o, enum.Enum):
            return o.value
        return super().default(o)


@attrs.define
class SavedSearch:
    """A search saved to the database by the user."""

    name: str
    search: SearchBuilder
    is_default: bool = False
    subscribed: bool = False

    def insert(self) -> None:
        conn = _DbState.connection()
        name = conn.execute(
            Sql.SELECT_UNIQUE_SEARCH_NAME.text(),
            {"new_name": self.name, "old_name": ""},
        ).fetchone()[0]
        conn.execute(
            "INSERT INTO saved_search (name, search, is_default, subscribed)"
            " VALUES (?, ?, ?, ?)",
            (name, self.search.to_json(), self.is_default, self.subscribed),
        )
        self.name = name

    def delete(self) -> None:
        _DbState.connection().execute(
            "DELETE FROM saved_search WHERE name = ?", (self.name,)
        )

    @classmethod
    def get(cls, name: str) -> SavedSearch | None:
        stmt = (
            "SELECT name, search, is_default, subscribed FROM saved_search"
            " WHERE name = ?"
        )
        row = _DbState.connection().execute(stmt, (name,)).fetchone()
        if row and (search := cls._validate_saved_search_row(*row)):
            return search
        return None

    @classmethod
    def get_default(cls) -> SavedSearch | None:
        stmt = (
            "SELECT name, search, is_default, subscribed FROM saved_search"
            " WHERE is_default = true"
        )
        row = _DbState.connection().execute(stmt).fetchone()
        if row and (search := cls._validate_saved_search_row(*row)):
            return search
        return None

    def update(self, new_name: str | None = None) -> None:
        conn = _DbState.connection()
        name = self.name
        if new_name:
            name = conn.execute(
                Sql.SELECT_UNIQUE_SEARCH_NAME.text(),
                {"new_name": new_name, "old_name": self.name},
            ).fetchone()[0]
        conn.execute(
            "UPDATE saved_search SET name = ?, search = ?, is_default = ?,"
            " subscribed = ? WHERE name = ?",
            (name, self.search.to_json(), self.is_default, self.subscribed, self.name),
        )
        self.name = name

    @classmethod
    def load_saved_searches(
        cls, subscribed_only: bool = False
    ) -> Iterable[SavedSearch]:
        stmt = (
            "SELECT name, search, is_default, subscribed FROM saved_search"
            f"{' WHERE subscribed' if subscribed_only else ''} ORDER BY name"
        )
        return (
            search
            for row in _DbState.connection().execute(stmt).fetchall()
            if (search := cls._validate_saved_search_row(*row))
        )

    @classmethod
    def _validate_saved_search_row(
        cls, name: str, json_str: str, is_default: int, subscribed: int
    ) -> SavedSearch | None:
        if search := SearchBuilder.from_json(json_str):
            return cls(name, search, bool(is_default), bool(subscribed))
        _DbState.connection().execute(
            "DELETE FROM saved_search WHERE name = ?", (name,)
        )
        logger.warning(f"Dropped invalid saved search '{name}'.")
        return None

    @classmethod
    def get_subscribed_song_ids(cls) -> Iterable[SongId]:
        if not (searches := list(cls.load_saved_searches(subscribed_only=True))):
            return []
        for search in searches:
            search.search.order = SongOrder.NONE
        stmt = "\nUNION\n".join(s.search.statement() for s in searches)
        params = tuple(p for s in searches for p in s.search.parameters())
        return (SongId(r[0]) for r in _DbState.connection().execute(stmt, params))


def _in_values_clause(attribute: str, values: list) -> str:
    return f"{attribute} IN ({', '.join('?' * len(values))})"


def _custom_data_clause(key_values: dict[str, list[str]]) -> str:
    return " AND ".join(
        f"(key = ? AND value IN ({', '.join('?' * len(vals))}))"
        for vals in key_values.values()
    )


def _in_ranges_clause(attribute: str, values: list[tuple[int, int | None]]) -> str:
    return " OR ".join(
        f"{attribute} >= ?{'' if val[1] is None else f' AND {attribute} < ?'}"
        for val in values
    )


def _fts5_phrases(text: str) -> str:
    """Turn each whitespace-separated word into an FTS5 prefix phrase."""
    return " ".join(f'"{s}"*' for s in text.replace('"', "").split(" ") if s)


def _fts5_start_phrase(text: str) -> str:
    """Turn the entire string into an FTS5 initial phrase."""
    return f'''^ "{text.replace('"', "")}"'''


# UsdbSong


def get_usdb_song(song_id: SongId) -> tuple | None:
    stmt = f"{Sql.SELECT_USDB_SONG.text()} WHERE usdb_song.song_id = ?"
    return _DbState.connection().execute(stmt, (song_id,)).fetchone()


def delete_usdb_song(song_id: SongId) -> None:
    _DbState.connection().execute("DELETE FROM usdb_song WHERE song_id = ?", (song_id,))


@attrs.define(frozen=True, slots=False)
class UsdbSongParams:
    """Parameters for inserting or updating a USDB song."""

    song_id: SongId
    usdb_mtime: int
    artist: str
    title: str
    language: str
    edition: str
    golden_notes: bool
    rating: float
    views: int
    sample_url: str
    year: int | None
    genre: str
    creator: str
    tags: str


def upsert_usdb_song(params: UsdbSongParams) -> None:
    stmt = Sql.UPSERT_USDB_SONG.text()
    _DbState.connection().execute(stmt, params.__dict__)


def upsert_usdb_songs(params: list[UsdbSongParams]) -> None:
    stmt = Sql.UPSERT_USDB_SONG.text()
    _DbState.connection().executemany(stmt, (p.__dict__ for p in params))


def set_usdb_song_status(song_id: SongId, status: DownloadStatus) -> None:
    stmt = Sql.UPSERT_USDB_SONG_STATUS.text()
    _DbState.connection().execute(stmt, {"song_id": song_id, "status": status.for_db()})


def set_usdb_song_playing(song_id: SongId, is_playing: bool) -> None:
    stmt = Sql.UPSERT_USDB_SONG_PLAYING.text()
    _DbState.connection().execute(stmt, {"song_id": song_id, "is_playing": is_playing})


def usdb_song_count() -> int:
    return _DbState.connection().execute("SELECT count(*) FROM usdb_song").fetchone()[0]


def max_usdb_song_id() -> SongId:
    row = _DbState.connection().execute("SELECT max(song_id) FROM usdb_song").fetchone()
    return SongId(row[0] or 0)


@attrs.define
class LastUsdbUpdate:
    """Last USDB update information."""

    usdb_mtime: int
    song_ids: list[SongId]

    @classmethod
    def get(cls) -> LastUsdbUpdate:
        rows = (
            _DbState.connection()
            .execute(
                "SELECT usdb_mtime, song_id FROM usdb_song WHERE usdb_mtime = "
                "(SELECT max(usdb_mtime) FROM usdb_song)"
            )
            .fetchall()
        )
        return cls(rows[0][0] if rows else 0, [SongId(r[1]) for r in rows])

    @classmethod
    def zero(cls) -> LastUsdbUpdate:
        return cls(0, [])

    def is_zero(self) -> bool:
        return self.usdb_mtime == 0


def delete_usdb_songs(ids: list[SongId]) -> None:
    _DbState.connection().execute(
        f"DELETE FROM usdb_song WHERE {_in_values_clause('song_id', ids)}", ids
    )


def all_local_usdb_songs() -> Iterable[SongId]:
    stmt = "SELECT DISTINCT song_id FROM sync_meta"
    return (SongId(r[0]) for r in _DbState.connection().execute(stmt))


def all_song_ids() -> Iterable[SongId]:
    rows = _DbState.connection().execute("SELECT song_id FROM usdb_song")
    return (SongId(r[0]) for r in rows)


def search_usdb_songs(search: SearchBuilder) -> Iterable[SongId]:
    rows = _DbState.connection().execute(search.statement(), tuple(search.parameters()))
    return (SongId(r[0]) for r in rows)


def find_similar_usdb_songs(artist: str, title: str) -> Iterable[SongId]:
    stmt = "SELECT rowid FROM fts_usdb_song WHERE artist MATCH ? AND title MATCH ?"
    params = (_fts5_start_phrase(artist), _fts5_start_phrase(title))
    return (SongId(r[0]) for r in _DbState.connection().execute(stmt, params))


def delete_session_data() -> None:
    _DbState.connection().execute("DELETE FROM session_usdb_song")


# song filters


def upsert_usdb_songs_languages(params: list[tuple[SongId, Iterable[str]]]) -> None:
    _DbState.connection().execute(
        f"DELETE FROM usdb_song_language WHERE {_in_values_clause('song_id', params)}",
        tuple(t[0] for t in params),
    )
    _DbState.connection().executemany(
        "INSERT INTO usdb_song_language (song_id, language) VALUES (?, ?)",
        ((song_id, lang) for song_id, langs in params for lang in langs),
    )


def upsert_usdb_songs_genres(params: list[tuple[SongId, Iterable[str]]]) -> None:
    _DbState.connection().execute(
        f"DELETE FROM usdb_song_genre WHERE {_in_values_clause('song_id', params)}",
        tuple(t[0] for t in params),
    )
    _DbState.connection().executemany(
        "INSERT INTO usdb_song_genre (song_id, genre) VALUES (?, ?)",
        ((song_id, lang) for song_id, langs in params for lang in langs),
    )


def upsert_usdb_songs_creators(params: list[tuple[SongId, Iterable[str]]]) -> None:
    _DbState.connection().execute(
        f"DELETE FROM usdb_song_creator WHERE {_in_values_clause('song_id', params)}",
        tuple(t[0] for t in params),
    )
    _DbState.connection().executemany(
        "INSERT INTO usdb_song_creator (song_id, creator) VALUES (?, ?)",
        ((song_id, lang) for song_id, langs in params for lang in langs),
    )


def usdb_song_artists() -> list[tuple[str, int]]:
    stmt = "SELECT artist, COUNT(*) FROM usdb_song GROUP BY artist ORDER BY artist"
    return _DbState.connection().execute(stmt).fetchall()


def usdb_song_titles() -> list[tuple[str, int]]:
    stmt = "SELECT title, COUNT(*) FROM usdb_song GROUP BY title ORDER BY title"
    return _DbState.connection().execute(stmt).fetchall()


def usdb_song_editions() -> list[tuple[str, int]]:
    stmt = "SELECT edition, COUNT(*) FROM usdb_song GROUP BY edition ORDER BY edition"
    return _DbState.connection().execute(stmt).fetchall()


def usdb_song_languages() -> list[tuple[str, int]]:
    stmt = (
        "SELECT language, COUNT(*) FROM usdb_song_language GROUP BY language ORDER BY"
        " language"
    )
    return _DbState.connection().execute(stmt).fetchall()


def usdb_song_years() -> list[tuple[int, int]]:
    stmt = (
        "SELECT year, COUNT(*) FROM usdb_song WHERE year IS NOT NULL "
        "GROUP BY year ORDER BY year"
    )
    return _DbState.connection().execute(stmt).fetchall()


def usdb_song_genres() -> list[tuple[str, int]]:
    stmt = "SELECT genre, COUNT(*) FROM usdb_song_genre GROUP BY genre ORDER BY genre"
    return _DbState.connection().execute(stmt).fetchall()


def usdb_song_creators() -> list[tuple[str, int]]:
    stmt = (
        "SELECT creator, COUNT(*) FROM usdb_song_creator GROUP BY creator ORDER BY "
        "creator"
    )
    return _DbState.connection().execute(stmt).fetchall()


def search_usdb_song_artists(search: str) -> set[str]:
    stmt = "SELECT artist FROM fts_usdb_song WHERE artist MATCH ?"
    rows = _DbState.connection().execute(stmt, (_fts5_phrases(search),)).fetchall()
    return {row[0] for row in rows}


def search_usdb_song_titles(search: str) -> set[str]:
    stmt = "SELECT title FROM fts_usdb_song WHERE title MATCH ?"
    rows = _DbState.connection().execute(stmt, (_fts5_phrases(search),)).fetchall()
    return {row[0] for row in rows}


def search_usdb_song_editions(search: str) -> set[str]:
    stmt = "SELECT edition FROM fts_usdb_song WHERE edition MATCH ?"
    rows = _DbState.connection().execute(stmt, (_fts5_phrases(search),)).fetchall()
    return {row[0] for row in rows}


def search_usdb_song_languages(search: str) -> set[str]:
    stmt = "SELECT language FROM fts_usdb_song WHERE language MATCH ?"
    rows = _DbState.connection().execute(stmt, (_fts5_phrases(search),)).fetchall()
    return {row[0] for row in rows}


def search_usdb_song_years(search: str) -> set[int]:
    stmt = "SELECT year FROM fts_usdb_song WHERE year MATCH ?"
    rows = _DbState.connection().execute(stmt, (_fts5_phrases(search),)).fetchall()
    return {row[0] for row in rows}


def search_usdb_song_genres(search: str) -> set[str]:
    stmt = "SELECT genre FROM fts_usdb_song WHERE genre MATCH ?"
    rows = _DbState.connection().execute(stmt, (_fts5_phrases(search),)).fetchall()
    return {row[0] for row in rows}


def search_usdb_song_creators(search: str) -> set[str]:
    stmt = "SELECT creator FROM fts_usdb_song WHERE creator MATCH ?"
    rows = _DbState.connection().execute(stmt, (_fts5_phrases(search),)).fetchall()
    return {row[0] for row in rows}


# SyncMeta


def get_in_folder(folder: Path) -> list[tuple]:
    stmt = f"{Sql.SELECT_SYNC_META.text()} WHERE path GLOB ? || '/*'"
    return _DbState.connection().execute(stmt, (folder.as_posix(),)).fetchall()


def reset_active_sync_metas(folder: Path) -> None:
    _DbState.connection().execute("DELETE FROM active_sync_meta")
    params = {"folder": folder.as_posix()}
    _DbState.connection().execute(Sql.INSERT_ACTIVE_SYNC_METAS.text(), params)


def update_active_sync_metas(folder: Path, song_id: SongId) -> None:
    _DbState.connection().execute(
        "DELETE FROM active_sync_meta WHERE song_id = ?", (song_id,)
    )
    params = {"folder": folder.as_posix(), "song_id": song_id}
    _DbState.connection().execute(Sql.INSERT_ACTIVE_SYNC_META.text(), params)


@attrs.define(frozen=True, slots=False)
class SyncMetaParams:
    """Parameters for inserting or updating a sync meta."""

    sync_meta_id: SyncMetaId
    song_id: SongId
    usdb_mtime: int
    path: str
    mtime: int
    meta_tags: str
    pinned: bool


def upsert_sync_meta(params: SyncMetaParams) -> None:
    stmt = Sql.UPSERT_SYNC_META.text()
    _DbState.connection().execute(stmt, params.__dict__)


def upsert_sync_metas(params: Iterable[SyncMetaParams]) -> None:
    stmt = Sql.UPSERT_SYNC_META.text()
    _DbState.connection().executemany(stmt, (p.__dict__ for p in params))


def delete_sync_meta(sync_meta_id: SyncMetaId) -> None:
    _DbState.connection().execute(
        "DELETE FROM sync_meta WHERE sync_meta_id = ?", (sync_meta_id,)
    )


def delete_sync_metas(ids: tuple[SyncMetaId, ...]) -> None:
    for batch in batched(ids, _SQL_VARIABLES_LIMIT):
        id_str = ", ".join("?" for _ in range(len(batch)))
        _DbState.connection().execute(
            f"DELETE FROM sync_meta WHERE sync_meta_id IN ({id_str})", batch
        )


def delete_sync_metas_in_folder(folder: Path, ids: tuple[SyncMetaId, ...]) -> None:
    path = folder.as_posix()
    for batch in batched(ids, _SQL_VARIABLES_LIMIT - 1):
        id_str = ", ".join("?" for _ in range(len(batch)))
        _DbState.connection().execute(
            f"DELETE FROM sync_meta WHERE sync_meta_id IN ({id_str}) AND "
            "path GLOB ? || '/*'",
            (*batch, path),
        )


@attrs.define(frozen=True, slots=False)
class CustomMetaDataParams:
    """Parameters for inserting or updating a resource file."""

    sync_meta_id: SyncMetaId
    key: str
    value: str


def upsert_custom_meta_data(params: Iterable[CustomMetaDataParams]) -> None:
    stmt = Sql.UPSERT_CUSTOM_META_DATA.text()
    _DbState.connection().executemany(stmt, (p.__dict__ for p in params))


def delete_custom_meta_data(ids: Iterable[SyncMetaId]) -> None:
    for batch in batched(ids, _SQL_VARIABLES_LIMIT):
        id_str = ", ".join("?" for _ in range(len(batch)))
        _DbState.connection().execute(
            f"DELETE FROM custom_meta_data WHERE sync_meta_id IN ({id_str})", batch
        )


def get_custom_data(sync_meta_id: SyncMetaId) -> dict[str, str]:
    return dict(
        _DbState.connection().execute(
            "SELECT key, value FROM custom_meta_data WHERE sync_meta_id = ?",
            (int(sync_meta_id),),
        )
    )


def get_custom_data_map() -> defaultdict[str, set[str]]:
    data: defaultdict[str, set[str]] = defaultdict(set)
    for key, value in _DbState.connection().execute(
        "SELECT DISTINCT key, value FROM custom_meta_data"
    ):
        data[key].add(value)
    return data


# ResourceFile


class ResourceKind(enum.StrEnum):
    """Kinds of resources."""

    TXT = "txt"
    AUDIO = "audio"
    VIDEO = "video"
    COVER = "cover"
    BACKGROUND = "background"


@attrs.define(frozen=True, slots=False)
class ResourceParams:
    """Parameters for inserting or updating a resource."""

    sync_meta_id: SyncMetaId
    kind: ResourceKind
    fname: str | None
    mtime: int | None
    resource: str | None
    status: JobStatus


def delete_resources(ids: Iterable[tuple[SyncMetaId, ResourceKind]]) -> None:
    for batch in batched(ids, _SQL_SMALLER_THAN_EXPRESSION_TREE):
        if not batch:
            continue
        conditions = " OR ".join("(sync_meta_id = ? AND kind = ?)" for _ in batch)
        params = tuple(param for i, k in batch for param in (int(i), k.value))
        _DbState.connection().execute(
            f"DELETE FROM resource_file WHERE {conditions}", params
        )


def upsert_resources(params: Iterable[ResourceParams]) -> None:
    stmt = Sql.UPSERT_RESOURCE.text()
    _DbState.connection().executemany(stmt, (p.__dict__ for p in params))


# Discord webhook


def maybe_insert_discord_notification(song_id: SongId, resource: str) -> bool:
    """Look to insert a discord notification into the database.

    Does nothing if already present. Otherwise returns True.
    """
    stmt = (
        "INSERT OR IGNORE INTO discord_notification (song_id, resource) VALUES (?, ?)"
    )
    return _DbState.connection().execute(stmt, (song_id, resource)).rowcount > 0
