"""SQLite metadata cache for the chart library — the `MetadataDB` class and
the query helpers it owns (keyset paging cursors, the tuning grouping key,
smart-arrangement naming, tag normalisation).

Extracted verbatim from ``server.py`` (R3). ``server.py`` still owns the
``meta_db`` singleton; this module only supplies the class, so nothing here
touches config paths at import time — the caller passes ``config_dir`` in.

Logs through the ``feedBack.server`` logger, unchanged from when this code
lived in ``server.py``, so existing log filters and caplog assertions still
resolve to the same logger object.
"""

import contextlib
import hashlib
import json
import logging
import math
import os
import re
import secrets
import sqlite3
import threading
import time
from pathlib import Path

from song import compute_smart_names

log = logging.getLogger("feedBack.server")


# Canonical Tuning-filter grouping key (feedBack#867). tuning_name collapses
# every non-standard tuning to "Custom Tuning"; for those rows we key on the
# raw offsets so distinct customs stay distinct, while named tunings keep
# grouping by name (stable across the offsets-column migration). Used by both
# the tuning-names listing and the filter WHERE so the contract matches.
def _tuning_group_key_sql(alias: str) -> str:
    """The tuning grouping key (name for named tunings, raw offsets for
    customs) against an explicit table alias — the grouped filter law (§7.1)
    evaluates chart-intrinsic predicates inside a member subquery, where bare
    column names would resolve against the wrong scope."""
    return (f"CASE WHEN {alias}.tuning_name = 'Custom Tuning' AND COALESCE({alias}.tuning_offsets, '') != '' "
            f"THEN {alias}.tuning_offsets ELSE {alias}.tuning_name END")


# ── SQLite metadata cache ─────────────────────────────────────────────────────

def _ensure_smart_names(arrangements: list[dict]) -> list[dict]:
    """Fill in missing ``smart_name`` fields and sort arrangements by smart order.

    Applied to every library query result so the client always receives
    arrangements in priority order:
      Lead → Alt. Lead [1,2,…] → Bonus Lead [1,2,…]
      → Rhythm → Alt. Rhythm → Bonus Rhythm
      → Bass → Alt. Bass → Bonus Bass → other

    Rows scanned before the smart-naming feature was introduced don't carry a
    ``smart_name`` key.  The background scanner automatically rescans those rows
    to populate the field from authoritative manifest JSON path flags.

    In the meantime this function provides a best-effort on-the-fly computation.
    However, when multiple arrangements share the same name (e.g. two "Combo"
    tracks in a archive that bundles all path flags as zero), name-based inference
    cannot distinguish Lead from Rhythm — so we emit ``smart_name: null`` and
    let the UI fall back to the legacy name until the background rescan corrects
    the row.  Arrangements that already have the field are never modified.
    """
    if not arrangements:
        return arrangements

    # Fill in missing smart_name values.
    if not all("smart_name" in a for a in arrangements):
        # Detect duplicate raw names across ALL arrangements (not just the
        # missing subset).  A duplicate anywhere means the name-based fallback
        # may assign the same smart type a scanned row already owns — emit
        # None for the missing entries and let the legacy name show through
        # until the background rescan corrects them.
        # Coerce to str so a malformed cached row with a list/dict name
        # doesn't blow up the set() conversion (and every query that hits it).
        all_names = [
            a.get("name", "") if isinstance(a.get("name"), str) else str(a.get("name", ""))
            for a in arrangements
        ]
        has_duplicates = len(all_names) != len(set(all_names))
        if has_duplicates:
            for a in arrangements:
                if "smart_name" not in a:
                    a["smart_name"] = None
        else:
            # No duplicates — name-based fallback is safe.
            from song import Arrangement as _ArrCls
            arr_objs = [
                _ArrCls(
                    name=a.get("name", ""),
                    path_lead=a.get("_path_lead", False),
                    path_rhythm=a.get("_path_rhythm", False),
                    path_bass=a.get("_path_bass", False),
                    bonus_arr=a.get("_bonus_arr", False),
                    represent=a.get("_represent", 0),
                )
                for a in arrangements
            ]
            smart = compute_smart_names(arr_objs)
            for a, sn in zip(arrangements, smart):
                if "smart_name" not in a:
                    a["smart_name"] = sn

    # Always sort by smart priority order so the client receives a consistent
    # list regardless of how the DB row was originally stored.
    # _arr_smart_sort_key is defined later in this module but resolved at
    # call-time, so the forward reference is safe.
    arrangements.sort(key=_arr_smart_sort_key)
    return arrangements


def _sqlite_file_integrity_ok(path: Path) -> bool:
    """True if `path` is a SQLite database that opens and passes
    `PRAGMA quick_check`. Used to gate a DB restore so a truncated or
    corrupt snapshot can never overwrite the live library DB."""
    try:
        with open(path, "rb") as f:
            if f.read(16) != b"SQLite format 3\x00":   # cheap header gate, no full read
                return False
    except OSError:
        return False
    conn = None
    try:
        conn = sqlite3.connect(str(path))
        row = conn.execute("PRAGMA quick_check").fetchone()
        return bool(row) and row[0] == "ok"
    except sqlite3.Error:
        return False
    finally:
        if conn is not None:
            conn.close()
        # quick_check on a non-WAL file makes no sidecars, but a malformed
        # file can; sweep them so a probe never litters config_dir.
        for suffix in ("-wal", "-shm"):
            try:
                path.with_name(path.name + suffix).unlink()
            except FileNotFoundError:
                pass


def _apply_pending_db_restore(config_dir: Path) -> None:
    """Swap in a library DB restored from a settings bundle, if one is
    staged. A settings import writes the restored snapshot to
    `web_library.db.restore` rather than over the live DB (the running
    server holds the old file open, and a stale `-wal`/`-shm` could be
    replayed onto a fresh main file → corruption). The swap happens here,
    at startup, BEFORE the connection opens: delete the old DB and its WAL
    sidecars, then rename the staged snapshot into place. The snapshot is a
    fully-checkpointed single file (SQLite online-backup API), so it needs
    no sidecars of its own. Idempotent and a no-op when nothing is staged.

    The staged file is re-validated here before anything is destroyed: a
    restore that fails its integrity check is discarded and the live DB is
    left untouched, so a bad bundle can never brick startup or lose data."""
    pending = config_dir / "web_library.db.restore"
    if not pending.exists():
        return
    if not _sqlite_file_integrity_ok(pending):
        log.error("pending library DB restore failed its integrity check; "
                  "discarding it and keeping the existing database")
        try:
            pending.unlink()
        except FileNotFoundError:
            pass
        return
    for suffix in ("", "-wal", "-shm"):
        try:
            (config_dir / f"web_library.db{suffix}").unlink()
        except FileNotFoundError:
            pass
    os.replace(pending, config_dir / "web_library.db")
    log.info("applied pending library DB restore from settings import")


# ── Keyset (cursor) pagination for the library grid (feedBack#636 item 3) ─────
# Forward-only, O(page) deep paging that doesn't grow with OFFSET. Only simple
# single-column sorts can keyset cleanly (the compound tuning/year sorts fall
# back to OFFSET). Every sort gets a unique `filename` tiebreak so the order is
# TOTAL — which also fixes a latent OFFSET skip/dupe across equal-key rows.
# (column, collate-clause, primary-direction) — tiebreak is always `filename` ASC.
_KEYSET_SORTS = {
    # artist/artist-desc left OUT deliberately: their ORDER BY carries a
    # title secondary (so cards within an artist read alphabetically, like
    # the tree view) which a two-term (value, filename) cursor can't seek
    # correctly — they page by OFFSET, which is measured-trivial at real
    # library sizes. Restore them with a composite sort-key column if
    # 50k-song libraries ever make OFFSET hurt.
    "title": ("title", "COLLATE NOCASE", "ASC"),
    "title-desc": ("title", "COLLATE NOCASE", "DESC"),
    "recent": ("mtime", "", "DESC"),
}
# Index into a query_page row tuple for each keyset column (see the SELECT in
# query_page: filename, title, artist, ... mtime at 9).
_KEYSET_ROW_IDX = {"artist": 2, "title": 1, "mtime": 9}


def _encode_cursor(values: list) -> str:
    import base64
    return base64.urlsafe_b64encode(json.dumps(values).encode("utf-8")).decode("ascii")


def _decode_cursor(cursor: str):
    """Decode an opaque keyset cursor to [sort_value, filename], or None if it's
    malformed (a bad cursor degrades to the first page, never 500s)."""
    import base64
    try:
        out = json.loads(base64.urlsafe_b64decode(cursor.encode("ascii")).decode("utf-8"))
    except (ValueError, TypeError):
        return None
    return out if isinstance(out, list) and len(out) == 2 else None


def _effective_keyset_sort(sort: str, direction: str) -> str:
    """Fold the legacy `dir=desc` toggle into the canonical keyset sort key, so
    the seek/cursor direction matches the ORDER BY that same toggle produces
    (without this, `sort=artist&dir=desc` would seek with `>` against a DESC
    order → gaps/dupes)."""
    if direction == "desc" and sort in ("artist", "title"):
        return sort + "-desc"
    return sort


def _keyset_seek(col: str, collate: str, primary_dir: str, cv, fn: str):
    """(sql, params) for 'rows strictly after (cv, fn)' in the total order
    `<col> <primary_dir>, filename ASC`, matching SQLite's NULL placement
    (NULLs sort first in ASC, last in DESC) so keyset is exactly OFFSET-
    equivalent even for NULL sort keys."""
    ce = f"{col} {collate}".strip()
    if primary_dir == "ASC":   # NULLs first
        if cv is None:
            return (f"(({col} IS NULL AND filename > ?) OR {col} IS NOT NULL)", [fn])
        return (f"({col} IS NOT NULL AND ({ce} > ? OR ({ce} = ? AND filename > ?)))",
                [cv, cv, fn])
    # DESC — NULLs last
    if cv is None:
        return (f"({col} IS NULL AND filename > ?)", [fn])
    return (f"({col} IS NULL OR ({col} IS NOT NULL AND "
            f"({ce} < ? OR ({ce} = ? AND filename > ?))))", [cv, cv, fn])


def next_library_cursor(sort: str, last_song: dict | None) -> str | None:
    """The cursor for the last row of a page, so the next request resumes after
    it. None when the sort can't keyset or the page was empty."""
    if sort not in _KEYSET_SORTS or not last_song:
        return None
    col = _KEYSET_SORTS[sort][0]
    key = "mtime" if col == "mtime" else col
    if key not in last_song or "filename" not in last_song:
        return None
    # A title display-override (Fix-metadata popup) replaces last_song["title"]
    # for the card, but the keyset seek runs on the RAW title column — resume
    # from the raw value query_page stashed (present only when the last row's
    # title was overridden), so paging never skips/dupes.
    val = (last_song["_sort_title"] if (key == "title" and "_sort_title" in last_song)
           else last_song[key])
    return _encode_cursor([val, last_song["filename"]])


# Song-level "mastered" threshold — best accuracy across a song's arrangements
# at/above this counts as in your repertoire. One number shared by the green
# accuracy badge, the Repertoire meter, the mastery filter/sort, and the P3
# growth-edge recommender (matches the frontend MASTERY_ACCURACY).
MASTERY_ACCURACY = 0.9


_SMART_TYPE_BASE: dict[str, int] = {"Lead": 0, "Rhythm": 10, "Bass": 20}


def _arr_smart_sort_key(entry: dict) -> tuple[int, int]:
    """Sort key for arrangement entries ordered by smart naming priority.

    Order: Lead → Alt. Lead [1,2,…] → Bonus Lead [1,2,…]
           → Rhythm → Alt. Rhythm → Bonus Rhythm
           → Bass → Alt. Bass → Bonus Bass → other (stable fallback)
    """
    sn = entry.get("smart_name")
    if not sn:
        return (99, 0)
    for label, base in _SMART_TYPE_BASE.items():
        if sn == label:
            return (base, 0)
        alt_prefix = f"Alt. {label}"
        if sn == alt_prefix:
            return (base + 1, 0)
        if sn.startswith(alt_prefix + " "):
            suffix = sn[len(alt_prefix) + 1:]
            return (base + 1, int(suffix) if suffix.isdigit() else 0)
        bonus_prefix = f"Bonus {label}"
        if sn == bonus_prefix:
            return (base + 2, 0)
        if sn.startswith(bonus_prefix + " "):
            suffix = sn[len(bonus_prefix) + 1:]
            return (base + 2, int(suffix) if suffix.isdigit() else 0)
    return (99, 0)


# Strips a trailing tag parenthetical from a filename stem — "(440Hz)",
# "(Live)", "(No Lead)", the retune/arrangement noise CDLC names carry.
_FN_TAG_RE = re.compile(r"\s*\([^)]*\)")


def _artist_title_from_filename(filename: str) -> dict | None:
    """Derive artist + title from the CDLC filename convention
    'Artist_Song-Title_v1_p.feedpak' — spaces written as hyphens WITHIN a
    field, underscores separating Artist | Title | version/arrangement. Used
    ONLY as a match SEED for packs whose own `artist` field is blank (a large
    slice of community charts): text search needs an artist, and the filename
    reliably carries it. This never becomes displayed metadata — the shown
    values still come from the confirmed MusicBrainz match (provenance
    'matched'), so nothing estimated is presented as author-set; if no match is
    found, the pack stays exactly as-is. Returns None when the name doesn't fit
    the convention (so a non-CDLC pack falls through untouched)."""
    base = filename.replace("\\", "/").rsplit("/", 1)[-1]
    base = base.rsplit(".", 1)[0]                 # drop the extension
    base = _FN_TAG_RE.sub("", base).strip()       # drop "(440Hz)" etc.
    parts = [p for p in base.split("_") if p]
    if len(parts) < 2:
        return None
    artist = parts[0].replace("-", " ").strip()
    title = parts[1].replace("-", " ").strip()
    if not artist or not title:
        return None
    return {"artist": artist, "title": title}


def _normalize_tag(tag) -> str:
    """Canonical form for a personal practice tag: trimmed, lowercased,
    internal whitespace collapsed, length-capped. Lowercasing is what keeps
    "Rock"/"rock" from splitting into two tags. Non-strings → ''."""
    if not isinstance(tag, str):
        return ""
    return " ".join(tag.strip().lower().split())[:60]


def _as_int(value) -> int:
    """Coerce a JSON value to an int, REJECTING bool and non-integral numbers
    so e.g. 1.9 / True don't silently truncate to 1. Accepts ints, integral
    floats (1.0), and integer-shaped strings ("5"); raises ValueError otherwise."""
    if isinstance(value, bool):
        raise ValueError("bool is not an integer")
    if isinstance(value, int):
        return value
    if isinstance(value, float):
        if not math.isfinite(value) or not value.is_integer():
            raise ValueError("non-integral float")
        return int(value)
    if isinstance(value, str):
        return int(value)   # int("5") ok; int("1.9")/"nan"/"inf" raise ValueError
    raise ValueError("not an integer")


class MetadataDB:
    def __init__(self, config_dir: Path):
        config_dir.mkdir(parents=True, exist_ok=True)
        _apply_pending_db_restore(config_dir)
        self.db_path = str(config_dir / "web_library.db")
        self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
        self.conn.execute("PRAGMA journal_mode=WAL")
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS songs (
                filename TEXT PRIMARY KEY,
                mtime REAL,
                size INTEGER,
                title TEXT,
                artist TEXT,
                album TEXT,
                year TEXT,
                duration REAL,
                tuning TEXT,
                arrangements TEXT,
                has_lyrics INTEGER DEFAULT 0,
                format TEXT DEFAULT 'archive',
                stem_count INTEGER DEFAULT 0,
                stem_ids TEXT DEFAULT '[]',
                tuning_name TEXT DEFAULT '',
                tuning_sort_key INTEGER DEFAULT 0,
                tuning_offsets TEXT DEFAULT '',
                genre TEXT DEFAULT '',
                track_number INTEGER,
                disc INTEGER
            )
        """)
        # Idempotent migrations for installs that predate each column.
        for ddl in (
            "ALTER TABLE songs ADD COLUMN format TEXT DEFAULT 'archive'",
            "ALTER TABLE songs ADD COLUMN stem_count INTEGER DEFAULT 0",
            # feedBack#129: per-stem filter needs the id list, not just count.
            "ALTER TABLE songs ADD COLUMN stem_ids TEXT DEFAULT '[]'",
            # feedBack#69 + #22: denormalized canonical tuning name + numeric
            # sort key (sum of offsets). The existing `tuning` text column
            # stays — these are caches, repopulated on rescan.
            "ALTER TABLE songs ADD COLUMN tuning_name TEXT DEFAULT ''",
            "ALTER TABLE songs ADD COLUMN tuning_sort_key INTEGER DEFAULT 0",
            # feedBack#867: raw per-string offsets (space-joined ints) so the
            # v3 client can render target notes and the Tuning filter can keep
            # distinct custom tunings distinct (tuning_name collapses them all
            # to "Custom Tuning"). Cache; repopulated on rescan.
            "ALTER TABLE songs ADD COLUMN tuning_offsets TEXT DEFAULT ''",
            # Primary genre from the feedpak `genres` list (spec 1.12.0). Cache;
            # repopulated on rescan.
            "ALTER TABLE songs ADD COLUMN genre TEXT DEFAULT ''",
            # Album track order from the feedpak `track`/`disc` fields (spec
            # 1.12.0). NULL when the pack doesn't author them; the album view
            # falls back to title order. Cache; repopulated on rescan.
            "ALTER TABLE songs ADD COLUMN track_number INTEGER",
            "ALTER TABLE songs ADD COLUMN disc INTEGER",
        ):
            try:
                self.conn.execute(ddl)
            except sqlite3.OperationalError:
                pass
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist COLLATE NOCASE)")
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title COLLATE NOCASE)")
        # Composite (sort col, filename) indexes cover the grid's ORDER BY +
        # its unique filename tiebreak — for both the OFFSET scan and keyset
        # seek (feedBack#636 item 3). idx_songs_artist/title above stay for the
        # distinct-artist / letter-bar aggregates.
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_artist_fn ON songs(artist COLLATE NOCASE, filename)")
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_title_fn ON songs(title COLLATE NOCASE, filename)")
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_mtime_fn ON songs(mtime, filename)")
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_tuning_name ON songs(tuning_name COLLATE NOCASE)")
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_genre ON songs(genre COLLATE NOCASE)")
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_tuning_sort_key ON songs(tuning_sort_key)")
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_year ON songs(year)")
        self.conn.execute("CREATE TABLE IF NOT EXISTS favorites (filename TEXT PRIMARY KEY)")
        # Personal, per-song metadata that must NEVER travel in the shared
        # feedpak file: a light 1–5 user-difficulty (planning only — distinct
        # from the authored 1–10 difficulty bands) + freeform notes. Likes are
        # NOT here — they stay the existing `favorites` heart (Christian's call).
        # A SEPARATE table (not `songs` columns) so a rescan's
        # `INSERT OR REPLACE INTO songs` can't wipe it; keyed by the same on-disk
        # filename as every other personal table. Additive + idempotent.
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS song_user_meta (
                filename TEXT PRIMARY KEY,
                user_difficulty INTEGER,   -- 1..5, NULL = unset
                notes TEXT,
                updated_at TEXT
            )
        """)
        # Free-form personal practice tags ("warm-ups", "riffs to nail") — an
        # intent practice-set primitive (Play-all-over-a-tag comes later). Tags
        # are normalized lowercase on write so "Rock"/"rock" don't split. Peer
        # of song_user_meta; same never-clobber rationale.
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS song_tags (
                filename TEXT NOT NULL,
                tag TEXT NOT NULL,
                created_at TEXT,
                PRIMARY KEY (filename, tag)
            )
        """)
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_tags_tag ON song_tags(tag COLLATE NOCASE)")
        # Per-field metadata OVERRIDES + LOCKS (the Fix-metadata popup). A
        # reversible DISPLAY overlay, never written to the pack: `value` is the
        # user's corrected value for a catalog field (title/artist/album/year/
        # genre), `locked=1` pins the field so a metadata refresh / auto-match
        # never changes what's shown for it (Plex-style field lock). Effective
        # display value = override → matched-MusicBrainz → pack → derived.
        # Filename-keyed → purged with the song on delete_song, NEVER on a
        # rescan (delete_missing), so an edit survives re-import like every other
        # local layer.
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS song_field_override (
                filename TEXT NOT NULL,
                field TEXT NOT NULL,        -- title|artist|album|year|genre
                value TEXT,                 -- corrected value (NULL = lock only, no override)
                locked INTEGER NOT NULL DEFAULT 0,
                updated_at TEXT,
                PRIMARY KEY (filename, field)
            )
        """)
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_field_override_fn ON song_field_override(filename)")
        # Artist-name aliases (P4): "ACDC" → "AC/DC", "the beatles" → "The Beatles".
        # A CANONICALIZATION OVERRIDE applied AT DISPLAY only — the scanner-derived
        # `songs.artist` and the feedpak files are never rewritten (a rescan can't
        # fight the user; one alias row fixes every matching song at once). Keyed by
        # the raw artist string (COLLATE NOCASE so case variants collapse), so it is
        # NOT filename-keyed → never touched by delete_missing/delete_song (an alias
        # outlives the songs that motivated it, ready for re-import). mb_artist_id is
        # reserved for a future confident MusicBrainz match (unused now).
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS artist_alias (
                raw_name TEXT PRIMARY KEY COLLATE NOCASE,
                canonical_name TEXT NOT NULL,
                mb_artist_id TEXT,
                updated_at TEXT
            )
        """)
        # ── Multi-chart grouping (P5a) ───────────────────────────────────────
        # A "work" is a song that may be charted by several feedpaks; each chart
        # stays its own `songs` row (unchanged), but they GROUP under a shared
        # work_key = normalize(artist+title). Two sparse, never-purged-on-rescan
        # override tables + one MATERIALIZED read-model so the grid can group
        # server-side without a query-time GROUP BY (which would kill the keyset
        # seek / A–Z / virtualization — see query_page).
        #
        # chart_group_pref: your chosen "keeper" chart per work (sparse; unset ⇒
        # auto-pick). Keyed by work_key, NOT filename, so it survives a chart's
        # rescan; an orphaned preferred (file gone) degrades to auto-pick.
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS chart_group_pref (
                work_key TEXT PRIMARY KEY,
                preferred_filename TEXT NOT NULL,
                updated_at TEXT
            )
        """)
        # chart_group_split: "these aren't the same song" escape hatch — a chart
        # gets its own unique split_key so it stands alone as a singleton work.
        # Filename-keyed → purged with the song on delete_song.
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS chart_group_split (
                filename TEXT PRIMARY KEY,
                split_key TEXT NOT NULL,
                updated_at TEXT
            )
        """)
        # work_display: the MATERIALIZED representative-filter read-model, rebuilt
        # from songs + the two override tables. One row per song:
        #   effective_work_key    = split_key if split else work_key
        #   is_group_representative = 1 for the keeper (pref or auto-pick) of a work
        #   group_size            = the ⚑ N charts in the work
        # Grouping-ON is then just `WHERE is_group_representative = 1` (keyset-safe).
        # A derived cache: filename-keyed, rebuilt on demand (dirty flag) — safe to
        # drop/rebuild, so it's purged on delete and re-materialized after a scan.
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS work_display (
                filename TEXT PRIMARY KEY,
                work_key TEXT NOT NULL,
                effective_work_key TEXT NOT NULL,
                is_group_representative INTEGER NOT NULL DEFAULT 1,
                group_size INTEGER NOT NULL DEFAULT 1
            )
        """)
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_work_display_rep ON work_display(is_group_representative)")
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_work_display_eff ON work_display(effective_work_key)")
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_work_display_wk ON work_display(work_key)")
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS loops (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                filename TEXT NOT NULL,
                name TEXT NOT NULL,
                start_time REAL NOT NULL,
                end_time REAL NOT NULL,
                created_at TEXT DEFAULT (datetime('now'))
            )
        """)
        # fee[dB]ack v0.3.0 — single-user player profile (id=1), streak, and the
        # unified XP store. Peers of favorites/loops; additive + idempotent.
        # `player_hash` is a future-leaderboard identity label (SHA-256 of the
        # first display name + a once-generated salt), never an auth credential.
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS profile (
                id INTEGER PRIMARY KEY CHECK (id = 1),
                display_name TEXT,
                avatar_path TEXT,
                player_hash TEXT,
                player_salt TEXT,
                onboarded INTEGER NOT NULL DEFAULT 0,
                created_at TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS profile_progress (
                id INTEGER PRIMARY KEY CHECK (id = 1),
                current_streak INTEGER NOT NULL DEFAULT 0,
                best_streak INTEGER NOT NULL DEFAULT 0,
                last_active_date TEXT          -- YYYY-MM-DD (local)
            )
        """)
        # Unified XP store: the single source of truth the profile badge reads.
        # Song-play, minigames, and tutorials all feed THIS via award_xp() — no
        # second XP curve (lib/xp.py owns the math).
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS xp_profile (
                id INTEGER PRIMARY KEY CHECK (id = 1),
                xp INTEGER NOT NULL DEFAULT 0,
                total_awards INTEGER NOT NULL DEFAULT 0,
                minigames_seeded INTEGER NOT NULL DEFAULT 0,
                updated_at TEXT
            )
        """)
        # Per-source XP ledger: the unified `xp` total above is a single number,
        # but a source (minigames, tutorials, song-play, …) needs to know its own
        # contribution so it can be reset/reversed independently (a minigames
        # profile-reset must subtract only its share, not song-play XP).
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS xp_sources (
                source TEXT PRIMARY KEY,
                xp INTEGER NOT NULL DEFAULT 0
            )
        """)
        # Per-song/arrangement practice stats (best score + accuracy, plays,
        # last position for Continue-Playing). Fed by the highway note-detection
        # scorer via POST /api/stats. Additive + idempotent; a 0.2.9 build
        # tolerates it and the new build opens an old db without it.
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS song_stats (
                filename TEXT NOT NULL,
                arrangement INTEGER NOT NULL DEFAULT 0,
                plays INTEGER NOT NULL DEFAULT 0,
                best_score INTEGER NOT NULL DEFAULT 0,
                best_accuracy REAL NOT NULL DEFAULT 0,
                last_score INTEGER NOT NULL DEFAULT 0,
                last_accuracy REAL NOT NULL DEFAULT 0,
                last_position REAL NOT NULL DEFAULT 0,
                last_played_at TEXT,
                updated_at TEXT,
                PRIMARY KEY (filename, arrangement)
            )
        """)
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_stats_recent ON song_stats(last_played_at DESC)")
        # Playlists + the reserved "Saved for Later" system playlist. Additive.
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS playlists (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                system_key TEXT,            -- 'saved_for_later' for reserved playlists, else NULL
                created_at TEXT,
                updated_at TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS playlist_songs (
                playlist_id INTEGER NOT NULL,
                filename TEXT NOT NULL,
                position INTEGER NOT NULL DEFAULT 0,
                PRIMARY KEY (playlist_id, filename)
            )
        """)
        self.conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_system_key ON playlists(system_key) WHERE system_key IS NOT NULL")
        # Smart collections (feedBack#636 item 2): a playlist row whose `rules`
        # JSON is non-NULL is a smart/dynamic collection — its membership is the
        # LIVE result of those library filter params, not a stored song list.
        # It surfaces as a registered library provider (the v3 source picker),
        # so it inherits the whole Songs UI. Additive, idempotent migration.
        try:
            self.conn.execute("ALTER TABLE playlists ADD COLUMN rules TEXT")
        except sqlite3.OperationalError:
            pass
        # Curated album (P6, metadata-design §7.2): a playlists row with
        # kind='album' is a hand-picked, ORDERED practice set of works with a
        # chosen chart per slot — the repeatable gameplay loop. Reuses the
        # playlist machinery wholesale (membership/order/cover/queue); the whole
        # schema delta is this `kind` discriminator plus two per-slot columns:
        # `arrangement` = the pinned arrangement NAME (names survive rescans;
        # the client resolves name→index at play), `work_key` = stamped at
        # add-time so a slot whose pinned chart is later deleted can self-heal
        # to the work's CURRENT preferred at read (never rewritten). Additive,
        # idempotent — same pattern as `rules` above.
        for _ddl in ("ALTER TABLE playlists ADD COLUMN kind TEXT",
                     "ALTER TABLE playlist_songs ADD COLUMN arrangement TEXT",
                     "ALTER TABLE playlist_songs ADD COLUMN work_key TEXT"):
            try:
                self.conn.execute(_ddl)
            except sqlite3.OperationalError:
                pass
        # Wishlist / "wanted" (feedBack#636 item 4): a persisted, actionable
        # list of songs the user does NOT own yet — the *arr "Wanted/Monitored"
        # analogue. Unlike playlists (which reference owned local songs by
        # filename), a wanted entry has no local file, so it lives in its own
        # table keyed by descriptive identity. Producers (the find_more plugin's
        # ownership-diff, or a manual add) POST here; the consuming UI reads it.
        # Additive + idempotent.
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS wanted (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                artist TEXT NOT NULL DEFAULT '',
                title TEXT NOT NULL DEFAULT '',
                source TEXT NOT NULL DEFAULT '',      -- e.g. 'find_more', 'manual'
                source_ref TEXT NOT NULL DEFAULT '',  -- opaque id/url within that source
                note TEXT NOT NULL DEFAULT '',
                created_at TEXT
            )
        """)
        # Identity = (artist, title, source, source_ref), case-insensitive on
        # the human fields, so re-running an ownership-diff doesn't duplicate.
        self.conn.execute(
            "CREATE UNIQUE INDEX IF NOT EXISTS idx_wanted_identity "
            "ON wanted(artist COLLATE NOCASE, title COLLATE NOCASE, source, source_ref)"
        )
        # Metadata-enrichment cache (P7, library-metadata design §4/§5/§6): one
        # row per song holding its match lifecycle + the canonical values a
        # confident match supplies. A CACHE/OVERRIDE layer — canonical values
        # are displayed, NEVER auto-written into the pack file. Never purged on
        # rescan (only by the explicit per-song delete); re-derivable, so a lost
        # row just re-enriches. `content_hash` keys the row to the metadata a
        # match depends on (normalized artist|title|album|duration — NOT the
        # filename), which makes enrichment idempotent AND rename-survivable.
        # match_state lifecycle: unscanned → matched(source,score) | manual |
        # failed. A `manual` row is the user's pinned pick — NEVER auto-reset;
        # `failed` retries on backoff via `attempts` (the matcher, P8, owns
        # that policy). Additive + idempotent.
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS song_enrichment (
                filename TEXT PRIMARY KEY,
                content_hash TEXT,
                match_state TEXT NOT NULL DEFAULT 'unscanned',
                match_source TEXT,
                match_score REAL,
                attempts INTEGER NOT NULL DEFAULT 0,
                mb_recording_id TEXT,
                mb_release_id TEXT,
                mb_artist_id TEXT,
                isrc TEXT,
                canon_artist TEXT,
                canon_album TEXT,
                canon_title TEXT,
                canon_year TEXT,
                canon_artist_sort TEXT,
                genres TEXT,
                art_cache_path TEXT,
                art_state TEXT,
                fetched_at TEXT
            )
        """)
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_enrichment_hash ON song_enrichment(content_hash)")
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_enrichment_state ON song_enrichment(match_state)")
        # P8 (the matcher): `candidates` holds the review tier's ranked
        # candidate list (JSON) so the Match-Review drawer never re-queries
        # MusicBrainz just to render; `last_attempt_at` anchors the failed-row
        # retry backoff (epoch seconds). Idempotent ALTERs, same pattern as
        # the `songs` migrations above.
        # R1 scraper options: `apply_mask` records which per-field auto-apply
        # toggles were OFF (suppressed) when an AUTOMATIC match settled the row,
        # as a canonical sorted comma-joined marker of blocked keys (''/NULL =
        # nothing suppressed). It keeps the per-field toggles to the same
        # "nothing forfeited" contract as the source/art toggles: re-enabling a
        # field re-queues affected `matched` rows for backfill (enrichment_pending)
        # and a partially-applied row is barred from seeding siblings
        # (enrichment_cache_lookup). Idempotent ALTER, same pattern as above.
        for ddl in (
            "ALTER TABLE song_enrichment ADD COLUMN candidates TEXT",
            "ALTER TABLE song_enrichment ADD COLUMN last_attempt_at REAL",
            "ALTER TABLE song_enrichment ADD COLUMN apply_mask TEXT",
        ):
            try:
                self.conn.execute(ddl)
            except sqlite3.OperationalError:
                pass
        # Artist-level enrichment cache (artist pages, launch charrette §5):
        # ONE row per matched MusicBrainz artist holding the whitelisted
        # url-relations (external links) + MB genres from a single throttled
        # artist lookup, fetched lazily on the first artist-page links request
        # and refreshed only on demand. Keyed by mb_artist_id (NOT the display
        # name), so alias merges / renames never orphan it. Never purged on
        # rescan — like song_enrichment, it is re-derivable but expensive
        # (rate-limited) to re-fetch. Additive + idempotent.
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS artist_enrichment (
                mb_artist_id TEXT PRIMARY KEY,
                url_rels TEXT,
                genres TEXT,
                fetched_at TEXT
            )
        """)
        # Progression (spec 010): instrument paths, challenges, quests, the
        # Decibels wallet, and the cosmetics shop. Targets/titles live in the
        # bundled content (data/progression/); these tables hold only player
        # state (counters, completion timestamps, spend, ownership) so content
        # edits update live displays without migrations. Additive + idempotent.
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS progression_state (
                id INTEGER PRIMARY KEY CHECK (id = 1),
                calibration_status TEXT NOT NULL DEFAULT 'pending',  -- pending|completed|skipped
                calibration_completed_at TEXT,
                created_at TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS player_paths (
                path_id TEXT PRIMARY KEY,          -- 'guitar' | 'bass' | 'drums' | future
                level INTEGER NOT NULL DEFAULT 0,
                selected_at TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS challenge_progress (
                challenge_id TEXT PRIMARY KEY,     -- namespaced 'guitar.l1.clean-run'
                path_id TEXT NOT NULL,
                level INTEGER NOT NULL,            -- the level whose set this belongs to
                count INTEGER NOT NULL DEFAULT 0,
                progress_detail TEXT,              -- JSON, e.g. {"seen": [...]} for distinct goals
                completed_at TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS quest_state (
                period_type TEXT NOT NULL,         -- 'daily' | 'weekly'
                period_key TEXT NOT NULL,          -- '2026-06-12' | '2026-W24'
                quest_id TEXT NOT NULL,
                count INTEGER NOT NULL DEFAULT 0,
                reward_db INTEGER NOT NULL DEFAULT 0,  -- snapshot at instantiation
                progress_detail TEXT,
                completed_at TEXT,
                PRIMARY KEY (period_type, period_key, quest_id)
            )
        """)
        # Spend is tracked separately from xp_profile.xp on purpose: the xp
        # total stays the monotonic lifetime-earned stat (db_earned goals,
        # xp_sources reset semantics) and balance = MAX(0, xp - spent).
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS wallet (
                id INTEGER PRIMARY KEY CHECK (id = 1),
                spent INTEGER NOT NULL DEFAULT 0
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS shop_owned (
                item_id TEXT PRIMARY KEY,
                cost_paid INTEGER NOT NULL DEFAULT 0,
                acquired_at TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS shop_equipped (
                slot TEXT PRIMARY KEY,             -- 'theme' | 'avatar_frame'
                item_id TEXT
            )
        """)
        # Ensure the singleton rows exist so reads never special-case "no row".
        self.conn.execute("INSERT OR IGNORE INTO profile (id, onboarded, created_at) VALUES (1, 0, datetime('now'))")
        self.conn.execute("INSERT OR IGNORE INTO profile_progress (id) VALUES (1)")
        self.conn.execute("INSERT OR IGNORE INTO xp_profile (id, xp, total_awards, updated_at) VALUES (1, 0, 0, datetime('now'))")
        self.conn.execute("INSERT OR IGNORE INTO progression_state (id, created_at) VALUES (1, datetime('now'))")
        self.conn.execute("INSERT OR IGNORE INTO wallet (id) VALUES (1)")
        self.conn.commit()
        self._lock = threading.Lock()
        # work_display (P5a) is a derived cache; True forces a (re)build on the
        # first grouped query and after any songs churn (put / delete / rescan).
        self._work_display_dirty = True
        # One-time repair of pre-fix rows written under URL-encoded filenames
        # (idempotent: a no-op once every row is canonical).
        self._migrate_decode_stat_filenames()

    def _song_exists(self, filename: str) -> bool:
        return self.conn.execute(
            "SELECT 1 FROM songs WHERE filename = ?", (filename,)).fetchone() is not None

    def _canonical_song_filename(self, filename: str) -> str:
        """Map a (possibly URL-encoded) filename to the `songs` library key.

        The recorder relays encodeURIComponent'd names ('/'→'%2F', ' '→'%20'),
        but `songs` keys on the decoded on-disk path. Decoding is LIBRARY-AWARE so
        a real filename that legitimately contains literal %XX is never corrupted:
        prefer the form that already exists in `songs`, and decode only when the
        decoded form resolves to a real song. When NEITHER form is in the library
        (e.g. a play recorded before the library scan finishes) keep the stored
        name unchanged — the next-startup migration canonicalizes it once the song
        is scanned, rather than risk corrupting a real %XX name now."""
        if not isinstance(filename, str):
            return filename
        if self._song_exists(filename):
            return filename                      # already a real library key (may contain %)
        from urllib.parse import unquote
        decoded = unquote(filename)
        if decoded != filename and self._song_exists(decoded):
            return decoded                       # encoded → real library key
        return filename                          # neither in library: leave as-is (heals on migrate)

    def _migrate_decode_stat_filenames(self):
        """Rewrite URL-encoded song_stats.filename rows to the decoded
        library-path key (the form `songs` uses). Pre-fix, the recorder stored
        encodeURIComponent'd names, so every recorded best was invisible to the
        reads that filter on `filename IN (SELECT filename FROM songs)`. Merge on
        collision — two encoded rows decoding to the same name, or an encoded row
        meeting an already-decoded one — with the same best=max / plays=sum /
        last-wins semantics as song_score.merge_stats, so the (filename,
        arrangement) primary key is never violated.

        Library-aware via the shared _canonical_song_filename rule: only decode a
        row when the decoded form is a real song, so a correctly-stored name
        containing literal %XX is never rewritten, and dead-song/orphan rows
        (neither form in the library) are left exactly as-is."""
        cols = self._STATS_COLS
        with self._lock:
            rows = [dict(zip(cols, r)) for r in self.conn.execute(
                "SELECT " + ", ".join(cols) + " FROM song_stats").fetchall()]
            canon = self._canonical_song_filename
            if all(canon(r["filename"]) == r["filename"] for r in rows):
                return  # every row already canonical (or an untouchable orphan)
            merged: dict = {}
            for r in rows:
                key = (canon(r["filename"]), int(r["arrangement"]))
                cur = merged.get(key)
                if cur is None:
                    merged[key] = dict(r, filename=key[0], arrangement=key[1])
                    continue
                # Most-recently-updated row wins the "last_*"/position fields.
                def _stamp(x):
                    return str(x.get("updated_at") or x.get("last_played_at") or "")
                newer = r if _stamp(r) >= _stamp(cur) else cur
                merged[key] = {
                    "filename": key[0], "arrangement": key[1],
                    "plays": (cur["plays"] or 0) + (r["plays"] or 0),
                    "best_score": max(cur["best_score"] or 0, r["best_score"] or 0),
                    "best_accuracy": max(cur["best_accuracy"] or 0.0, r["best_accuracy"] or 0.0),
                    "last_score": newer["last_score"], "last_accuracy": newer["last_accuracy"],
                    "last_position": newer["last_position"],
                    "last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"],
                }
            # Atomic swap: clear and reinsert the canonicalized set in one txn.
            try:
                self.conn.execute("DELETE FROM song_stats")
                self.conn.executemany(
                    "INSERT INTO song_stats (" + ", ".join(cols) + ") VALUES ("
                    + ", ".join("?" * len(cols)) + ")",
                    [tuple(m[c] for c in cols) for m in merged.values()],
                )
                self.conn.commit()
            except Exception:
                self.conn.rollback()
                raise

    def is_favorite(self, filename: str) -> bool:
        return self.conn.execute("SELECT 1 FROM favorites WHERE filename = ?", (filename,)).fetchone() is not None

    def toggle_favorite(self, filename: str) -> bool:
        """Toggle favorite status. Returns new state."""
        with self._lock:
            if self.is_favorite(filename):
                self.conn.execute("DELETE FROM favorites WHERE filename = ?", (filename,))
                self.conn.commit()
                return False
            else:
                self.conn.execute("INSERT OR IGNORE INTO favorites VALUES (?)", (filename,))
                self.conn.commit()
                return True

    # ── Personal per-song metadata: user-difficulty / notes / tags ───────────
    # All keyed by the on-disk `songs` filename and kept OUT of the shared
    # feedpak file. Likes are the `favorites` heart, deliberately NOT duplicated
    # here. Reads are lock-free (WAL); writes take self._lock like the rest.
    def get_song_user_meta(self, filename: str) -> dict:
        """{'user_difficulty', 'notes', 'tags'} for one song (tags sorted)."""
        row = self.conn.execute(
            "SELECT user_difficulty, notes FROM song_user_meta WHERE filename = ?",
            (filename,)).fetchone()
        tags = [r[0] for r in self.conn.execute(
            "SELECT tag FROM song_tags WHERE filename = ? ORDER BY tag COLLATE NOCASE",
            (filename,)).fetchall()]
        return {
            "user_difficulty": (row[0] if row else None),
            "notes": ((row[1] if row else None) or ""),
            "tags": tags,
        }

    def set_song_user_meta(self, filename: str, *,
                           user_difficulty="__keep__", notes="__keep__") -> dict:
        """Partial upsert of the personal fields. Pass a value to set it, None to
        clear it, or leave it out (sentinel `__keep__`) to preserve the current
        one. When nothing personal remains the row is dropped so an
        unset-everything leaves no empty shell. Returns the merged meta."""
        with self._lock:
            cur = self.conn.execute(
                "SELECT user_difficulty, notes FROM song_user_meta WHERE filename = ?",
                (filename,)).fetchone()
            cur_diff = cur[0] if cur else None
            cur_notes = cur[1] if cur else None
            new_diff = cur_diff if user_difficulty == "__keep__" else user_difficulty
            new_notes = cur_notes if notes == "__keep__" else notes
            if new_diff is None and not (new_notes or "").strip():
                self.conn.execute("DELETE FROM song_user_meta WHERE filename = ?", (filename,))
            else:
                self.conn.execute(
                    "INSERT INTO song_user_meta (filename, user_difficulty, notes, updated_at) "
                    "VALUES (?, ?, ?, datetime('now')) "
                    "ON CONFLICT(filename) DO UPDATE SET "
                    "user_difficulty = excluded.user_difficulty, "
                    "notes = excluded.notes, updated_at = excluded.updated_at",
                    (filename, new_diff, (new_notes or None)))
            self.conn.commit()
        return self.get_song_user_meta(filename)

    # ── Per-field metadata overrides + locks (Fix-metadata popup) ─────────────
    def get_song_overrides(self, filename: str) -> dict:
        """{field: {"value": str|None, "locked": bool}} for one song."""
        rows = self.conn.execute(
            "SELECT field, value, locked FROM song_field_override WHERE filename = ?",
            (filename,)).fetchall()
        return {r[0]: {"value": r[1], "locked": bool(r[2])} for r in rows}

    def set_song_override(self, filename: str, field: str, *,
                          value="__keep__", locked="__keep__") -> dict:
        """Partial upsert of one field's override value and/or lock. Pass a
        value/locked to set it or leave the sentinel to keep the current one. A
        row with neither a value nor a lock is dropped (no empty shell). Returns
        the song's full override map."""
        with self._lock:
            cur = self.conn.execute(
                "SELECT value, locked FROM song_field_override WHERE filename = ? AND field = ?",
                (filename, field)).fetchone()
            new_val = (cur[0] if cur else None) if value == "__keep__" else value
            new_lock = (bool(cur[1]) if cur else False) if locked == "__keep__" else bool(locked)
            new_val = (new_val or "").strip() or None
            if new_val is None and not new_lock:
                self.conn.execute(
                    "DELETE FROM song_field_override WHERE filename = ? AND field = ?",
                    (filename, field))
            else:
                self.conn.execute(
                    "INSERT INTO song_field_override (filename, field, value, locked, updated_at) "
                    "VALUES (?, ?, ?, ?, datetime('now')) "
                    "ON CONFLICT(filename, field) DO UPDATE SET "
                    "value = excluded.value, locked = excluded.locked, updated_at = excluded.updated_at",
                    (filename, field, new_val, 1 if new_lock else 0))
            self.conn.commit()
        return self.get_song_overrides(filename)

    def locked_fields(self, filename: str) -> set:
        """The catalog fields the user LOCKED for a song (Fix-metadata popup).
        An automatic match must never (re)canonicalize these, and gap-fill must
        never write them to the file. Locked read (the enrichment worker calls
        it), minimal projection."""
        with self._lock:
            return {r[0] for r in self.conn.execute(
                "SELECT field FROM song_field_override WHERE filename = ? AND locked = 1",
                (filename,)).fetchall()}

    def clear_song_override(self, filename: str, field: str) -> dict:
        """Remove a field's override + lock entirely (revert to the resolved
        pack/matched value)."""
        with self._lock:
            self.conn.execute(
                "DELETE FROM song_field_override WHERE filename = ? AND field = ?",
                (filename, field))
            self.conn.commit()
        return self.get_song_overrides(filename)

    def overrides_map(self, filenames) -> dict:
        """{filename: {field: {value, locked}}} for a batch — feeds the grid's
        effective-value resolution (display slice). Chunked under SQLite's
        variable limit."""
        fns = list(filenames)
        out: dict = {}
        for i in range(0, len(fns), 400):
            chunk = fns[i:i + 400]
            if not chunk:
                break
            q = ("SELECT filename, field, value, locked FROM song_field_override "
                 "WHERE filename IN (%s)" % ",".join("?" * len(chunk)))
            for fn, field, value, locked in self.conn.execute(q, chunk).fetchall():
                out.setdefault(fn, {})[field] = {"value": value, "locked": bool(locked)}
        return out

    def _romaji_display(self, filename: str, artist: str, title: str):
        """English-base display fallback. A blank-artist CDLC pack named
        'Artist_Title_v1_p' has no readable name (artist blank; title = the raw
        filename), and a match would fill it with the artist's NATIVE script
        (kanji/kana). Surface the author's own romaji parsed from the filename
        instead, so an English base reads 'Junko Yagami - BAY CITY'. Only kicks in
        when the pack has no artist of its own — a real pack artist is untouched."""
        if (artist or "").strip():
            return artist, title
        d = _artist_title_from_filename(filename)
        return (d["artist"], d["title"]) if d else (artist, title)

    def pack_fields(self, filename: str) -> dict:
        """The stored (pack) values for the overridable catalog fields — the
        Fix-metadata popup shows these behind each override as the 'revert to
        pack' reference + the Yours/Pack provenance. Empty strings for a missing
        song so the popup always has a value to render."""
        keys = ("title", "artist", "album", "year", "genre")
        row = self.conn.execute(
            "SELECT title, artist, album, year, genre FROM songs WHERE filename = ?",
            (filename,)).fetchone()
        vals = {k: ((row[i] or "") if row else "") for i, k in enumerate(keys)}
        # Baseline the author's romaji (from the filename) for a blank-artist pack,
        # so the Details tab's Pack reference matches what the grid shows.
        vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
        return vals

    # Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the
    # scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias)
    # so a corrected genre is browsable — the correlated subquery is used ONLY
    # when genre overrides actually exist; the common case stays on the plain
    # indexed `genre` column. Genre stays a library-only overlay (it isn't a
    # write-to-file field), so it never touches the pack.
    _EFFECTIVE_GENRE_SQL = (
        "COALESCE((SELECT o.value FROM song_field_override o "
        "WHERE o.filename = songs.filename AND o.field = 'genre' "
        "AND o.value IS NOT NULL AND o.value != ''), genre)"
    )

    def _has_genre_overrides(self) -> bool:
        return self.conn.execute(
            "SELECT 1 FROM song_field_override WHERE field = 'genre' "
            "AND value IS NOT NULL AND value != '' LIMIT 1").fetchone() is not None

    def _effective_genre_expr(self) -> str:
        """`genre` normally; the override-aware COALESCE only when overrides exist."""
        return self._EFFECTIVE_GENRE_SQL if self._has_genre_overrides() else "genre"

    def set_song_tags(self, filename: str, tags) -> list:
        """Replace ALL of a song's tags with the given set (each normalized;
        blanks + case-dupes dropped). Full-replace so the whole personal-meta
        blob edits as a unit. Returns the stored tag list (sorted, like reads)."""
        norm: list = []
        seen: set = set()
        for t in (tags or []):
            nt = _normalize_tag(t)
            if nt and nt not in seen:
                seen.add(nt)
                norm.append(nt)
        # Bound the number of tags so one PUT can't write unbounded rows.
        # Per-tag length is already capped in _normalize_tag; cap the count too.
        norm = norm[:50]
        with self._lock:
            self.conn.execute("DELETE FROM song_tags WHERE filename = ?", (filename,))
            if norm:
                self.conn.executemany(
                    "INSERT OR IGNORE INTO song_tags (filename, tag, created_at) "
                    "VALUES (?, ?, datetime('now'))",
                    [(filename, t) for t in norm])
            self.conn.commit()
        return self.get_song_user_meta(filename)["tags"]

    def all_tags(self) -> list:
        """[{tag, count}] over songs that still exist, most-used first — powers
        the tag filter UI. Excludes tags whose only songs were deleted."""
        rows = self.conn.execute(
            "SELECT tag, COUNT(*) c FROM song_tags "
            "WHERE filename IN (SELECT filename FROM songs) "
            "GROUP BY tag ORDER BY c DESC, tag COLLATE NOCASE").fetchall()
        return [{"tag": r[0], "count": r[1]} for r in rows]

    def user_meta_map(self, filenames) -> dict:
        """Batch {filename: user_difficulty} for a set of rows (set values
        only). Lets query_page / query_artists embed difficulty without an
        N+1. Chunked under SQLite's variable limit — query_artists can pass
        every song across 50 artists, well past a single IN (...)."""
        fns = list(filenames)
        out: dict = {}
        for i in range(0, len(fns), 400):
            chunk = fns[i:i + 400]
            if not chunk:
                break
            ph = ",".join("?" * len(chunk))
            rows = self.conn.execute(
                f"SELECT filename, user_difficulty FROM song_user_meta "
                f"WHERE filename IN ({ph}) AND user_difficulty IS NOT NULL", chunk).fetchall()
            for fn, diff in rows:
                out[fn] = diff
        return out

    def tags_map(self, filenames) -> dict:
        """Batch {filename: [tags]} for a page of rows."""
        fns = list(filenames)
        if not fns:
            return {}
        ph = ",".join("?" * len(fns))
        rows = self.conn.execute(
            f"SELECT filename, tag FROM song_tags WHERE filename IN ({ph}) "
            f"ORDER BY tag COLLATE NOCASE", fns).fetchall()
        out: dict = {}
        for fn, tag in rows:
            out.setdefault(fn, []).append(tag)
        return out

    def purge_song_user_data(self, filename: str) -> None:
        """Drop all personal rows for a deleted song. Called by delete_song
        INSIDE the caller's `meta_db._lock` — must not re-acquire the lock."""
        self.conn.execute("DELETE FROM song_user_meta WHERE filename = ?", (filename,))
        self.conn.execute("DELETE FROM song_tags WHERE filename = ?", (filename,))
        self.conn.execute("DELETE FROM song_field_override WHERE filename = ?", (filename,))

    def batch_user_meta(self, filenames, *, set_difficulty="__keep__",
                        add_tags=None, remove_tags=None) -> int:
        """Apply personal-meta edits across MANY songs in one transaction —
        the bulk-edit primitive behind the batch bar. Additive by design so a
        bulk action never silently clobbers per-song data the user can't see:

        - `set_difficulty`: an int 1–5 sets it on every song; `None` clears it
          on every song; the `__keep__` sentinel leaves each song's own value
          untouched (mixed-state "leave unchanged"). Notes are preserved; a row
          that ends up difficulty-less AND notes-less is dropped (no empty shell,
          matching set_song_user_meta).
        - `add_tags` / `remove_tags`: tag sets ADDED to / REMOVED from each song
          (never a full-replace — bulk must not wipe a song's other tags). A tag
          in both add and remove resolves to add (explicit set wins).

        Returns the count of songs touched. Caller normalizes tags is NOT
        assumed — we normalize here so the endpoint and the DB agree."""
        add = []
        seen: set = set()
        for t in (add_tags or []):
            nt = _normalize_tag(t)
            if nt and nt not in seen:
                seen.add(nt)
                add.append(nt)
        rem = {nt for nt in (_normalize_tag(t) for t in (remove_tags or [])) if nt}
        rem -= set(add)  # add wins a conflict
        fns = list(dict.fromkeys(filenames or []))  # dedupe, keep order
        if not fns:
            return 0
        with self._lock:
            for fn in fns:
                if set_difficulty != "__keep__":
                    cur = self.conn.execute(
                        "SELECT notes FROM song_user_meta WHERE filename = ?",
                        (fn,)).fetchone()
                    cur_notes = cur[0] if cur else None
                    if set_difficulty is None and not (cur_notes or "").strip():
                        self.conn.execute(
                            "DELETE FROM song_user_meta WHERE filename = ?", (fn,))
                    else:
                        self.conn.execute(
                            "INSERT INTO song_user_meta (filename, user_difficulty, notes, updated_at) "
                            "VALUES (?, ?, ?, datetime('now')) "
                            "ON CONFLICT(filename) DO UPDATE SET "
                            "user_difficulty = excluded.user_difficulty, "
                            "updated_at = excluded.updated_at",
                            (fn, set_difficulty, cur_notes))
                if rem:
                    ph = ",".join("?" * len(rem))
                    self.conn.execute(
                        f"DELETE FROM song_tags WHERE filename = ? AND tag IN ({ph})",
                        [fn, *rem])
                if add:
                    self.conn.executemany(
                        "INSERT OR IGNORE INTO song_tags (filename, tag, created_at) "
                        "VALUES (?, ?, datetime('now'))",
                        [(fn, t) for t in add])
            self.conn.commit()
        return len(fns)

    # ── Player profile (fee[dB]ack v0.3.0) ─────────────────────────────────
    def get_profile(self) -> dict:
        row = self.conn.execute(
            "SELECT display_name, avatar_path, player_hash, onboarded FROM profile WHERE id = 1"
        ).fetchone()
        if not row:
            return {"display_name": None, "avatar_url": None, "player_hash": None, "onboarded": False}
        return {
            "display_name": row[0],
            "avatar_url": row[1],
            "player_hash": row[2],
            "onboarded": bool(row[3]),
        }

    def set_profile(self, display_name: str, avatar_url: str | None) -> dict:
        """Set/update the display name (+ avatar). Computes player_hash ONCE
        from the first name + a stored random salt; it stays stable across
        later name changes. Marks onboarded=1."""
        with self._lock:
            cur = self.conn.execute(
                "SELECT player_hash, player_salt FROM profile WHERE id = 1"
            ).fetchone()
            player_hash = cur[0] if cur else None
            salt = cur[1] if cur else None
            if not player_hash:
                salt = secrets.token_hex(16)
                player_hash = hashlib.sha256((display_name + salt).encode("utf-8")).hexdigest()
            self.conn.execute(
                "UPDATE profile SET display_name = ?, "
                "avatar_path = COALESCE(?, avatar_path), "
                "player_hash = ?, player_salt = ?, onboarded = 1 WHERE id = 1",
                (display_name, avatar_url, player_hash, salt),
            )
            self.conn.commit()
        return self.get_profile()

    # ── Unified XP store ────────────────────────────────────────────────────
    def get_xp(self) -> int:
        row = self.conn.execute("SELECT xp FROM xp_profile WHERE id = 1").fetchone()
        return int(row[0]) if row else 0

    def award_xp(self, amount: int, source: str | None = None) -> int:
        """Add XP to the unified store; returns the new total. `amount` may be
        NEGATIVE — used internally to REVERSE a failed award (the total and the
        per-source bucket both clamp at 0). `source` (when given) is tracked in
        the xp_sources ledger so it can be reset independently.

        Service boundary: the plugin hook (context["award_xp"]) passes this
        straight through, so coerce defensively — bad input (bool, NaN/Inf,
        non-integral, out-of-int64-range) must neither raise NOR mutate state.
        _as_int rejects bool/non-integral; bad → no-op (0)."""
        try:
            amount = _as_int(amount)
        except (TypeError, ValueError, OverflowError):
            amount = 0
        amount = max(-10_000_000, min(amount, 10_000_000))
        with self._lock:
            # MAX(0, …) clamps the result so a reversal can't drive XP negative.
            self.conn.execute(
                "UPDATE xp_profile SET xp = MAX(0, xp + ?), "
                "total_awards = total_awards + ?, updated_at = datetime('now') WHERE id = 1",
                (amount, 1 if amount > 0 else 0),
            )
            if source:
                self.conn.execute(
                    "INSERT INTO xp_sources (source, xp) VALUES (?, MAX(0, ?)) "
                    "ON CONFLICT(source) DO UPDATE SET xp = MAX(0, xp + ?)",
                    (source, amount, amount),
                )
            self.conn.commit()
            row = self.conn.execute("SELECT xp FROM xp_profile WHERE id = 1").fetchone()
        return int(row[0]) if row else 0

    def reset_source_xp(self, source: str) -> dict:
        """Subtract a single source's tracked contribution from the unified
        total and zero its bucket (e.g. a minigames profile-reset removes only
        minigames XP, leaving song-play/tutorials XP intact). Returns progress."""
        with self._lock:
            row = self.conn.execute("SELECT xp FROM xp_sources WHERE source = ?", (source,)).fetchone()
            amt = int(row[0]) if row and row[0] else 0
            if amt:
                self.conn.execute(
                    "UPDATE xp_profile SET xp = MAX(0, xp - ?), updated_at = datetime('now') WHERE id = 1",
                    (amt,),
                )
            self.conn.execute("UPDATE xp_sources SET xp = 0 WHERE source = ?", (source,))
            self.conn.commit()
        return self.get_progress()

    def seed_xp_once(self, amount: int, marker: str = "minigames") -> bool:
        """One-time seed of the unified store from a pre-unification source
        (e.g. the minigames plugin's profile.json), so existing earned XP is
        preserved. No-ops if already seeded or the store already has XP.
        Returns True if it seeded."""
        # Same no-raise / no-silent-mutate contract as award_xp(): this is a
        # plugin-facing service (context["seed_xp"]). _as_int rejects bool /
        # non-integral; bad input becomes a 0 (no-op) seed rather than raising.
        try:
            amount = _as_int(amount)
        except (TypeError, ValueError, OverflowError):
            amount = 0
        amount = max(0, min(amount, 10_000_000))
        if marker != "minigames":
            return False
        with self._lock:
            row = self.conn.execute(
                "SELECT xp, minigames_seeded FROM xp_profile WHERE id = 1"
            ).fetchone()
            xp_now, seeded = (row[0], row[1]) if row else (0, 0)
            if seeded or xp_now > 0 or amount <= 0:
                if not seeded:
                    self.conn.execute("UPDATE xp_profile SET minigames_seeded = 1 WHERE id = 1")
                    self.conn.commit()
                return False
            self.conn.execute(
                "UPDATE xp_profile SET xp = ?, minigames_seeded = 1, updated_at = datetime('now') WHERE id = 1",
                (amount,),
            )
            # Record the seeded amount in the source ledger too, so a later
            # minigames reset subtracts the migrated XP rather than orphaning it.
            self.conn.execute(
                "INSERT INTO xp_sources (source, xp) VALUES (?, ?) "
                "ON CONFLICT(source) DO UPDATE SET xp = xp + ?",
                (marker, amount, amount),
            )
            self.conn.commit()
        return True

    # ── Streak ──────────────────────────────────────────────────────────────
    def record_active_day(self, today: str) -> dict:
        """Mark `today` (YYYY-MM-DD, local) as an active day. Any session on a
        calendar day keeps the streak: yesterday→+1, today→unchanged, gap or
        first-ever→reset to 1. Updates best_streak."""
        from datetime import date, timedelta
        with self._lock:
            row = self.conn.execute(
                "SELECT current_streak, best_streak, last_active_date FROM profile_progress WHERE id = 1"
            ).fetchone()
            cur, best, last = (row[0], row[1], row[2]) if row else (0, 0, None)
            if last != today:
                try:
                    yesterday = (date.fromisoformat(today) - timedelta(days=1)).isoformat()
                except ValueError:
                    yesterday = None
                cur = cur + 1 if (last and last == yesterday) else 1
                best = max(best or 0, cur)
                self.conn.execute(
                    "UPDATE profile_progress SET current_streak = ?, best_streak = ?, last_active_date = ? WHERE id = 1",
                    (cur, best, today),
                )
                self.conn.commit()
                last = today
        return {"current_streak": cur, "best_streak": best, "last_active_date": last}

    def get_progress(self) -> dict:
        """The full profile-badge payload: XP/level (lib/xp) + streak."""
        from xp import progress as _xp_progress
        p = self.conn.execute(
            "SELECT current_streak, best_streak, last_active_date FROM profile_progress WHERE id = 1"
        ).fetchone()
        cur, best, last = (p[0], p[1], p[2]) if p else (0, 0, None)
        out = _xp_progress(self.get_xp())
        out.update({"current_streak": cur, "best_streak": best, "last_active_date": last})
        return out

    # ── Progression (spec 010): paths, challenges, quests, wallet, shop ────
    # Lock discipline: self._lock is NOT reentrant and award_xp() takes it, so
    # record_progression_event() applies state inside the lock but awards quest
    # dB (and re-enters for quest_completed goals) only after releasing it.

    def get_progression_state(self) -> dict:
        row = self.conn.execute(
            "SELECT calibration_status, calibration_completed_at FROM progression_state WHERE id = 1"
        ).fetchone()
        status = row[0] if row else "pending"
        return {"calibration_status": status, "calibration_completed_at": row[1] if row else None}

    def skip_calibration(self) -> dict:
        """pending → skipped (no-op once completed/skipped). Either way the
        player holds onboarding rank 1 afterwards."""
        with self._lock:
            self.conn.execute(
                "UPDATE progression_state SET calibration_status = 'skipped' "
                "WHERE id = 1 AND calibration_status = 'pending'"
            )
            self.conn.commit()
        return self.get_progression_state()

    def get_player_paths(self) -> dict:
        """{path_id: level} for every selected path."""
        rows = self.conn.execute("SELECT path_id, level FROM player_paths").fetchall()
        return {r[0]: int(r[1]) for r in rows}

    def add_player_paths(self, path_ids) -> dict:
        """Select paths (idempotent; re-adding never resets a level)."""
        with self._lock:
            for pid in path_ids:
                self.conn.execute(
                    "INSERT OR IGNORE INTO player_paths (path_id, level, selected_at) "
                    "VALUES (?, 0, datetime('now'))",
                    (pid,),
                )
            self.conn.commit()
        return self.get_player_paths()

    def get_challenge_state(self) -> dict:
        """{challenge_id: {count, completed, detail}} for every touched challenge."""
        rows = self.conn.execute(
            "SELECT challenge_id, count, progress_detail, completed_at FROM challenge_progress"
        ).fetchall()
        out = {}
        for cid, count, detail, completed_at in rows:
            try:
                parsed = json.loads(detail) if detail else None
            except (ValueError, TypeError):
                parsed = None
            out[cid] = {
                "count": int(count or 0),
                "completed": completed_at is not None,
                "completed_at": completed_at,
                "detail": parsed,
            }
        return out

    def ensure_quest_period(self, content, now) -> None:
        """Lazily instantiate the current daily/weekly quest rows (deterministic
        per period key; rewards snapshot so live quests survive content edits)."""
        import progression as progression_mod
        keys = progression_mod.period_keys(now)
        with self._lock:
            for period_type in ("daily", "weekly"):
                cfg = (content.get("quests") or {}).get(period_type) or {}
                pool = cfg.get("pool") or {}
                count = int(cfg.get("count") or 0)
                if not pool or count < 1:
                    continue
                key = keys[period_type]
                exists = self.conn.execute(
                    "SELECT 1 FROM quest_state WHERE period_type = ? AND period_key = ? LIMIT 1",
                    (period_type, key),
                ).fetchone()
                if exists:
                    continue
                for qid in progression_mod.select_quests(pool.keys(), period_type, key, count):
                    self.conn.execute(
                        "INSERT OR IGNORE INTO quest_state "
                        "(period_type, period_key, quest_id, reward_db) VALUES (?, ?, ?, ?)",
                        (period_type, key, qid, int(pool[qid].get("reward_db") or 0)),
                    )
            self.conn.commit()

    def get_quest_rows(self, period_keys_map: dict) -> list:
        """Current-period quest instances as snapshot/API rows."""
        out = []
        for period_type, key in period_keys_map.items():
            rows = self.conn.execute(
                "SELECT quest_id, count, reward_db, progress_detail, completed_at "
                "FROM quest_state WHERE period_type = ? AND period_key = ? ORDER BY quest_id",
                (period_type, key),
            ).fetchall()
            for qid, count, reward, detail, completed_at in rows:
                try:
                    parsed = json.loads(detail) if detail else None
                except (ValueError, TypeError):
                    parsed = None
                out.append({
                    "period_type": period_type,
                    "period_key": key,
                    "quest_id": qid,
                    "count": int(count or 0),
                    "reward_db": int(reward or 0),
                    "detail": parsed,
                    "completed": completed_at is not None,
                    "completed_at": completed_at,
                })
        return out

    def get_wallet(self) -> dict:
        """{balance, lifetime_db, spent} — see the wallet table comment for
        why spend never mutates xp_profile.xp."""
        import progression as progression_mod
        row = self.conn.execute("SELECT spent FROM wallet WHERE id = 1").fetchone()
        spent = int(row[0]) if row and row[0] else 0
        lifetime = self.get_xp()
        return {
            "balance": progression_mod.wallet_balance(lifetime, spent),
            "lifetime_db": lifetime,
            "spent": spent,
        }

    def buy_shop_item(self, item: dict) -> tuple:
        """Atomic purchase: balance check + spend + ownership in one
        transaction. Returns ("ok"|"owned"|"insufficient", wallet)."""
        with self._lock:
            owned = self.conn.execute(
                "SELECT 1 FROM shop_owned WHERE item_id = ?", (item["id"],)
            ).fetchone()
            if owned:
                status = "owned"
            else:
                xp_row = self.conn.execute("SELECT xp FROM xp_profile WHERE id = 1").fetchone()
                spent_row = self.conn.execute("SELECT spent FROM wallet WHERE id = 1").fetchone()
                balance = max(0, int(xp_row[0] if xp_row else 0) - int(spent_row[0] if spent_row else 0))
                cost = int(item.get("cost") or 0)
                if cost < 0:
                    status = "invalid"
                elif balance < cost:
                    status = "insufficient"
                else:
                    self.conn.execute(
                        "UPDATE wallet SET spent = spent + ? WHERE id = 1", (cost,)
                    )
                    self.conn.execute(
                        "INSERT INTO shop_owned (item_id, cost_paid, acquired_at) "
                        "VALUES (?, ?, datetime('now'))",
                        (item["id"], cost),
                    )
                    self.conn.commit()
                    status = "ok"
        return status, self.get_wallet()

    def get_owned_items(self) -> dict:
        rows = self.conn.execute(
            "SELECT item_id, cost_paid, acquired_at FROM shop_owned"
        ).fetchall()
        return {r[0]: {"cost_paid": int(r[1] or 0), "acquired_at": r[2]} for r in rows}

    def get_equipped(self) -> dict:
        rows = self.conn.execute("SELECT slot, item_id FROM shop_equipped").fetchall()
        return {r[0]: r[1] for r in rows if r[1]}

    def equip_item(self, slot: str, item_id) -> dict:
        """Equip an owned item into a slot (item_id=None unequips)."""
        with self._lock:
            if item_id is None:
                self.conn.execute("DELETE FROM shop_equipped WHERE slot = ?", (slot,))
            else:
                self.conn.execute(
                    "INSERT INTO shop_equipped (slot, item_id) VALUES (?, ?) "
                    "ON CONFLICT(slot) DO UPDATE SET item_id = excluded.item_id",
                    (slot, item_id),
                )
            self.conn.commit()
        return self.get_equipped()

    def progression_snapshot(self, content, now) -> dict:
        """The plain-dict state view lib/progression.evaluate_event reads."""
        import progression as progression_mod
        keys = progression_mod.period_keys(now)
        streak_row = self.conn.execute(
            "SELECT current_streak FROM profile_progress WHERE id = 1"
        ).fetchone()
        return {
            "calibration_status": self.get_progression_state()["calibration_status"],
            "paths": self.get_player_paths(),
            "challenges": self.get_challenge_state(),
            "quests": self.get_quest_rows(keys),
            "streak": int(streak_row[0]) if streak_row and streak_row[0] else 0,
            "xp_total": self.get_xp(),
        }

    def record_progression_event(self, event_type: str, payload, content,
                                 now=None, _depth: int = 0) -> dict:
        """The single progression choke point: evaluate one event, persist the
        deltas, award quest dB, and re-enter once for quest_completed goals.
        Returns a toast-ready summary."""
        import progression as progression_mod
        from datetime import datetime as _dt
        now = now or _dt.now()
        self.ensure_quest_period(content, now)
        snapshot = self.progression_snapshot(content, now)
        outcome = progression_mod.evaluate_event(
            {"type": event_type, "payload": payload or {}}, content, snapshot
        )
        keys = progression_mod.period_keys(now)
        challenge_index = content.get("challenge_index") or {}
        quest_pools = content.get("quests") or {}

        summary = {
            "challenges_completed": [],
            "quests_completed": [],
            "level_ups": list(outcome["level_ups"]),
            "calibration_completed": bool(outcome["calibration_completed"]),
        }
        with self._lock:
            for ch in outcome["challenges"]:
                detail = json.dumps(ch["detail"]) if ch.get("detail") else None
                self.conn.execute(
                    "INSERT INTO challenge_progress "
                    "(challenge_id, path_id, level, count, progress_detail, completed_at) "
                    "VALUES (?, ?, ?, ?, ?, CASE WHEN ? THEN datetime('now') END) "
                    "ON CONFLICT(challenge_id) DO UPDATE SET "
                    "count = excluded.count, progress_detail = excluded.progress_detail, "
                    "completed_at = COALESCE(challenge_progress.completed_at, excluded.completed_at)",
                    (ch["challenge_id"], ch["path_id"], ch["level"], ch["count"],
                     detail, 1 if ch["completed"] else 0),
                )
                if ch["completed"]:
                    info = challenge_index.get(ch["challenge_id"]) or {}
                    title = (info.get("challenge") or {}).get("title") or ch["challenge_id"]
                    summary["challenges_completed"].append(
                        {"id": ch["challenge_id"], "title": title, "path_id": ch["path_id"]}
                    )
            for lu in outcome["level_ups"]:
                # Guard on the old level so a stale evaluation can't double-bump.
                self.conn.execute(
                    "UPDATE player_paths SET level = ? WHERE path_id = ? AND level = ?",
                    (lu["new_level"], lu["path_id"], lu["new_level"] - 1),
                )
            # Only quests whose row actually TRANSITIONED to completed in this
            # call get rewarded/re-entered. The pure outcome was computed from
            # a pre-lock snapshot, so a concurrent event may have completed the
            # same quest first — its guarded UPDATE (completed_at IS NULL)
            # then touches 0 rows here, and paying it again would double-award
            # Decibels and double-advance quest_completed challenges.
            newly_completed_quests = []
            for q in outcome["quests"]:
                detail = json.dumps(q["detail"]) if q.get("detail") else None
                cur = self.conn.execute(
                    "UPDATE quest_state SET count = ?, progress_detail = ?, "
                    "completed_at = COALESCE(completed_at, CASE WHEN ? THEN datetime('now') END) "
                    "WHERE period_type = ? AND period_key = ? AND quest_id = ? AND completed_at IS NULL",
                    (q["count"], detail, 1 if q["completed"] else 0,
                     q["period_type"], keys.get(q["period_type"], ""), q["quest_id"]),
                )
                if q["completed"] and cur.rowcount > 0:
                    newly_completed_quests.append(q)
            if outcome["calibration_completed"]:
                self.conn.execute(
                    "UPDATE progression_state SET calibration_status = 'completed', "
                    "calibration_completed_at = datetime('now') "
                    "WHERE id = 1 AND calibration_status != 'completed'"
                )
            self.conn.commit()

        # Quest awards + bounded re-entry, outside the lock (award_xp locks).
        for q in newly_completed_quests:
            pool = (quest_pools.get(q["period_type"]) or {}).get("pool") or {}
            qdef = pool.get(q["quest_id"]) or {}
            summary["quests_completed"].append({
                "id": q["quest_id"],
                "title": qdef.get("title") or q["quest_id"],
                "period_type": q["period_type"],
                "reward_db": q["reward_db"],
            })
            if q["reward_db"]:
                self.award_xp(q["reward_db"], "quests")
            if _depth < 1:
                sub = self.record_progression_event(
                    "quest_completed",
                    {"period_type": q["period_type"], "quest_id": q["quest_id"]},
                    content, now=now, _depth=_depth + 1,
                )
                summary["challenges_completed"].extend(sub["challenges_completed"])
                summary["quests_completed"].extend(sub["quests_completed"])
                summary["level_ups"].extend(sub["level_ups"])

        summary["mastery_rank"] = progression_mod.mastery_rank(
            self.get_progression_state()["calibration_status"], self.get_player_paths()
        )
        return summary

    # ── Per-song practice stats ───────────────────────────────────────────---
    _STATS_COLS = (
        "filename", "arrangement", "plays", "best_score", "best_accuracy",
        "last_score", "last_accuracy", "last_position", "last_played_at", "updated_at",
    )

    def _stats_row(self, filename: str, arrangement: int) -> dict | None:
        r = self.conn.execute(
            "SELECT " + ", ".join(self._STATS_COLS) +
            " FROM song_stats WHERE filename = ? AND arrangement = ?",
            (filename, int(arrangement)),
        ).fetchone()
        return dict(zip(self._STATS_COLS, r)) if r else None

    # Constant SQL fragment restricting stats reads to songs that still exist.
    # Unconditional: a genuinely empty (but scanned) library must still hide
    # stale stats/playlist ghosts. We rely on `songs` NEVER being transiently
    # empty mid-scan — /api/rescan/full bumps mtime to force a full re-scan
    # rather than DELETEing rows — so the only times `songs` is empty are a
    # fresh install (no stats anyway) or a truly empty library (ghosts should be
    # hidden). Race-free orphan handling: dead-song stats are hidden here, never
    # deleted on scan (see delete_missing).
    _EXISTING_SONG_FILTER = " AND filename IN (SELECT filename FROM songs) "

    def _existing_song_filter(self) -> str:
        return self._EXISTING_SONG_FILTER

    # ── Artist-name canonicalization (P4) ─────────────────────────────────────
    # "Apply at display": resolve songs.artist through the artist_alias override
    # for the deduped dropdown/tree (query_artists) — else keep the raw name. The
    # correlated PK-lookup subquery is fine for the offset-paged catalog; the grid
    # FILTER instead expands a canonical name to its raw variants (index-friendly,
    # keyset-safe), and the grid DISPLAY re-labels rows in Python via alias_map().
    _EFFECTIVE_ARTIST_SQL = (
        "COALESCE((SELECT aa.canonical_name FROM artist_alias aa "
        "WHERE aa.raw_name = songs.artist COLLATE NOCASE), songs.artist)"
    )

    def alias_map(self) -> dict:
        """{raw_name_lower: canonical_name} for every alias — one read to re-label
        a page of grid rows without an N+1. Lowercased keys so the lookup matches
        the raw artist case-insensitively (the table is COLLATE NOCASE)."""
        return {r[0].lower(): r[1] for r in self.conn.execute(
            "SELECT raw_name, canonical_name FROM artist_alias").fetchall()}

    def effective_artist(self, raw: str, amap: dict | None = None) -> str:
        """Canonical display name for a raw artist (alias override else itself)."""
        if raw is None:
            return raw
        amap = self.alias_map() if amap is None else amap
        return amap.get(raw.lower(), raw)

    def _single_hop_canonical(self, name: str) -> str | None:
        """The stored canonical for a raw name (a SINGLE hop), or None if `name`
        is not itself an alias key. Case-insensitive (the table is COLLATE NOCASE)
        — the shared primitive the chain-flatteners reuse."""
        if not name:
            return None
        row = self.conn.execute(
            "SELECT canonical_name FROM artist_alias WHERE raw_name = ? COLLATE NOCASE",
            (name,)).fetchone()
        return row[0] if row else None

    def _terminal_canonical(self, name: str) -> str:
        """Follow the alias chain from `name` to its TERMINAL canonical — the first
        name that is not itself an alias key — so transitive chains (raw → mid →
        … → terminal) collapse to one hop. A visited-set breaks cycles: if we come
        back to a name already seen we return the last name reached rather than
        looping. Reuses the single-hop primitive."""
        seen: set = set()
        cur = name
        while True:
            key = (cur or "").lower()
            if key in seen:
                return cur           # cycle — stop, return where we are
            seen.add(key)
            nxt = self._single_hop_canonical(cur)
            if nxt is None or (nxt or "").lower() == key:
                return cur           # not an alias key (or self) → terminal
            cur = nxt

    def _raw_variants_for(self, canonical: str) -> list:
        """Every raw artist string that should match a filter on `canonical`: the
        canonical name itself plus all raw names aliased to it (case-insensitive).
        Lets the artist filter be `artist IN (...)` — uses the artist index and is
        keyset-safe, instead of a per-row COALESCE subquery."""
        rows = self.conn.execute(
            "SELECT raw_name FROM artist_alias WHERE canonical_name = ? COLLATE NOCASE",
            (canonical,)).fetchall()
        seen, out = set(), []
        for name in [canonical, *[r[0] for r in rows]]:
            k = (name or "").lower()
            if name and k not in seen:
                seen.add(k)
                out.append(name)
        return out

    def list_artist_aliases(self) -> list:
        """All alias rows (raw → canonical), canonical then raw, for the Tidy-up
        'current merges' list."""
        rows = self.conn.execute(
            "SELECT raw_name, canonical_name, mb_artist_id FROM artist_alias "
            "ORDER BY canonical_name COLLATE NOCASE, raw_name COLLATE NOCASE").fetchall()
        return [{"raw_name": r[0], "canonical_name": r[1], "mb_artist_id": r[2]} for r in rows]

    def _set_artist_alias_locked(self, raw_name: str, canonical_name: str,
                                 mb_artist_id: str | None = None) -> dict:
        """Core upsert — assumes self._lock is HELD and does NOT commit (so the
        single set and the batch merge can share one transaction). Flattens chains
        and guards cycles:

        * A self-alias (raw == canonical) DROPs any existing row (the UI un-merge).
        * Otherwise `canonical` is resolved to its TERMINAL canonical, so setting a
          new hop onto an existing chain collapses to one hop rather than growing a
          two-hop chain that grouping/filtering would then split.
        * Cycle guard: if that terminal IS `raw`, storing would loop the chain back
          on itself — we no-op and report it so the caller can surface a failure.
        * Forward-flatten: any existing rows whose canonical == `raw` are re-pointed
          to the new terminal, so previously-merged variants follow `raw` onward.

        Returns a result dict {ok, raw_name, canonical_name, ...}."""
        raw = (raw_name or "").strip()
        canon = (canonical_name or "").strip()
        if not raw or not canon:
            raise ValueError("raw_name and canonical_name are required")
        if raw.lower() == canon.lower():
            self.conn.execute("DELETE FROM artist_alias WHERE raw_name = ? COLLATE NOCASE", (raw,))
            return {"ok": True, "raw_name": raw, "canonical_name": raw, "unmerged": True}
        terminal = self._terminal_canonical(canon)
        if (terminal or "").lower() == raw.lower():
            # raw → … → raw would be a cycle; refuse rather than corrupt the chain.
            return {"ok": False, "reason": "cycle", "raw_name": raw,
                    "canonical_name": canon, "terminal": terminal}
        self.conn.execute(
            "INSERT INTO artist_alias (raw_name, canonical_name, mb_artist_id, updated_at) "
            "VALUES (?, ?, ?, datetime('now')) "
            "ON CONFLICT(raw_name) DO UPDATE SET "
            "canonical_name = excluded.canonical_name, "
            "mb_artist_id = excluded.mb_artist_id, updated_at = excluded.updated_at",
            (raw, terminal, mb_artist_id))
        # Re-point any variants that were previously merged INTO raw onto the new
        # terminal (raw itself now aliases onward, so it can't stay a canonical).
        self.conn.execute(
            "UPDATE artist_alias SET canonical_name = ?, updated_at = datetime('now') "
            "WHERE canonical_name = ? COLLATE NOCASE AND raw_name != ? COLLATE NOCASE",
            (terminal, raw, terminal))
        return {"ok": True, "raw_name": raw, "canonical_name": terminal}

    def set_artist_alias(self, raw_name: str, canonical_name: str,
                         mb_artist_id: str | None = None) -> dict:
        """Upsert one raw→canonical override (chain-flattened, cycle-guarded — see
        _set_artist_alias_locked). Returns the result dict."""
        with self._lock:
            result = self._set_artist_alias_locked(raw_name, canonical_name, mb_artist_id)
            self.conn.commit()
        return result

    def remove_artist_alias(self, raw_name: str) -> None:
        with self._lock:
            self.conn.execute("DELETE FROM artist_alias WHERE raw_name = ? COLLATE NOCASE", (raw_name,))
            self.conn.commit()

    def merge_artists(self, raw_names, canonical_name: str) -> int:
        """Point several raw artist names at one canonical (the Tidy-up merge).
        Skips the canonical's own self-alias. Returns the count of aliases written.
        ATOMIC: the whole batch runs under one lock and one commit, so a mid-batch
        cycle rejection can't leave a half-applied merge."""
        canon = (canonical_name or "").strip()
        if not canon:
            raise ValueError("canonical_name is required")
        n = 0
        with self._lock:
            for raw in (raw_names or []):
                r = (raw or "").strip()
                if r and r.lower() != canon.lower():
                    result = self._set_artist_alias_locked(r, canon)
                    if result.get("ok"):
                        n += 1
            self.conn.commit()
        return n

    def raw_artists(self, limit: int = 2000) -> list:
        """Distinct RAW artist names in the library with song counts + their
        current canonical (for the Tidy-up picker — you merge raw variants). Raw,
        not effective, so both 'ACDC' and 'AC/DC' show as separate mergeable rows."""
        limit = max(1, min(10000, int(limit)))
        amap = self.alias_map()
        rows = self.conn.execute(
            "SELECT artist, COUNT(*) c FROM songs WHERE artist IS NOT NULL AND artist != '' "
            "GROUP BY artist COLLATE NOCASE ORDER BY c DESC, artist COLLATE NOCASE LIMIT ?",
            (limit,)).fetchall()
        return [{"name": r[0], "count": r[1],
                 "canonical": amap.get((r[0] or "").lower(), r[0])} for r in rows]

    # ── Artist pages (launch charrette PR-B) ─────────────────────────────────
    # The artist page is "X *in your library*" — a shelf plus your relationship
    # to it, never a discography browser (locked position 1). Everything here
    # reads LOCAL rows only; the external-links layer (artist_enrichment) is a
    # separate lazy cache keyed by mb_artist_id.

    def artist_known_mb_id(self, variants: list) -> str | None:
        """The artist's MusicBrainz id, if any of their songs' enrichment rows
        carry one. Only `matched`/`manual` rows count (partial coverage is the
        contract — degrade gracefully); the most common id wins so one stray
        wrong match can't out-vote the rest of the shelf."""
        if not variants:
            return None
        ph = ",".join(["?"] * len(variants))
        row = self.conn.execute(
            f"SELECT e.mb_artist_id, COUNT(*) c FROM song_enrichment e "
            f"JOIN songs s ON s.filename = e.filename "
            f"WHERE s.artist COLLATE NOCASE IN ({ph}) "
            f"AND e.match_state IN ('matched', 'manual') "
            f"AND e.mb_artist_id IS NOT NULL AND e.mb_artist_id != '' "
            f"GROUP BY e.mb_artist_id ORDER BY c DESC, e.mb_artist_id LIMIT 1",
            variants).fetchone()
        return row[0] if row else None

    def artist_page(self, name: str) -> dict:
        """The all-LOCAL artist-page payload: canonical name (alias-aware),
        the raw variants it merges, song/album counts, the albums list, the
        mastered count (DENOMINATOR LAW, locked position 2: every number
        counts songs YOU OWN — the WHERE is `artist IN (your variants)` over
        `songs`, never anything external), mb_artist_id when known, header-
        mosaic art, similar-in-library via genre co-occurrence (locked
        position 3: only artists already in the library, empty → hidden), and
        the play-all file list. An unknown name returns a zero-count page (an
        unmatched artist is still a fully functional page)."""
        from urllib.parse import quote
        canonical = self._terminal_canonical((name or "").strip())
        variants = self._raw_variants_for(canonical)
        ph = ",".join(["?"] * len(variants)) if variants else "?"
        rows = self.conn.execute(
            f"SELECT filename, title, album, year, genre FROM songs "
            f"WHERE title != '' AND artist COLLATE NOCASE IN ({ph}) "
            f"ORDER BY album COLLATE NOCASE, (track_number IS NULL) ASC, "
            f"COALESCE(disc, 1), track_number, title COLLATE NOCASE",
            variants or [canonical]).fetchall()
        # Albums: distinct non-empty album names in shelf order, each with the
        # earliest authored year, a track count, and a representative cover
        # song (the first row → also the mosaic's source).
        albums: dict = {}
        album_order: list = []
        for fn, _t, album, year, _g in rows:
            key = (album or "").strip()
            if not key:
                continue
            k = key.lower()
            if k not in albums:
                albums[k] = {"name": key, "year": (year or ""), "count": 0, "cover": fn}
                album_order.append(k)
            albums[k]["count"] += 1
            if not albums[k]["year"] and year:
                albums[k]["year"] = year
        album_list = [albums[k] for k in album_order]
        # "also shown as": the raw variants actually present in the library
        # (the canonical itself is the headline, so it's excluded).
        vrows = self.conn.execute(
            f"SELECT artist, COUNT(*) FROM songs "
            f"WHERE title != '' AND artist COLLATE NOCASE IN ({ph}) "
            f"GROUP BY artist COLLATE NOCASE ORDER BY COUNT(*) DESC",
            variants or [canonical]).fetchall()
        shown_as = [{"name": r[0], "count": r[1]} for r in vrows
                    if (r[0] or "").lower() != (canonical or "").lower()]
        # Mastered / practice presence — over THIS artist's library songs only.
        mastered = 0
        has_stats = False
        fns = [r[0] for r in rows]
        if fns:
            fph = ",".join(["?"] * len(fns))
            srows = self.conn.execute(
                f"SELECT filename, MAX(best_accuracy) FROM song_stats "
                f"WHERE filename IN ({fph}) GROUP BY filename", fns).fetchall()
            has_stats = len(srows) > 0
            mastered = sum(1 for _fn, acc in srows
                           if acc is not None and acc >= MASTERY_ACCURACY)
        # Similar in your library: other artists sharing songs.genre values,
        # ranked by distinct shared genres then by how many of their songs sit
        # in those genres. Raw artist rows are folded through the alias map so
        # "ACDC" and "AC/DC" rank as one artist; self is excluded either way.
        genres = sorted({(r[4] or "").strip().lower() for r in rows} - {""})
        similar: list = []
        if genres:
            gph = ",".join(["?"] * len(genres))
            grows = self.conn.execute(
                f"SELECT artist, COUNT(DISTINCT lower(genre)), COUNT(*) FROM songs "
                f"WHERE title != '' AND genre != '' AND lower(genre) IN ({gph}) "
                f"AND artist IS NOT NULL AND artist != '' "
                f"GROUP BY artist COLLATE NOCASE", genres).fetchall()
            amap = self.alias_map()
            agg: dict = {}
            for raw, shared, n in grows:
                canon = amap.get((raw or "").lower(), raw)
                if (canon or "").lower() == (canonical or "").lower():
                    continue
                cur = agg.setdefault((canon or "").lower(),
                                     {"artist": canon, "shared_genres": 0, "count": 0})
                cur["shared_genres"] = max(cur["shared_genres"], shared)
                cur["count"] += n
            similar = sorted(
                agg.values(),
                key=lambda a: (-a["shared_genres"], -a["count"], (a["artist"] or "").lower())
            )[:5]
        # Header mosaic (locked position 10: MB hosts no artist images — the
        # default is a mosaic of OWNED album art via the playlist-cover
        # grammar): one representative song per album first, then fill from
        # the remaining songs, up to 4.
        seen: set = set()
        art_files: list = []
        for al in album_list:
            if al["cover"] not in seen:
                seen.add(al["cover"])
                art_files.append(al["cover"])
            if len(art_files) >= 4:
                break
        if len(art_files) < 4:
            for fn in fns:
                if fn not in seen:
                    seen.add(fn)
                    art_files.append(fn)
                if len(art_files) >= 4:
                    break
        return {
            "artist": canonical,
            "variants": shown_as,
            "song_count": len(rows),
            "album_count": len(album_list),
            "mastered_count": mastered,
            "has_stats": has_stats,
            "albums": album_list,
            "mb_artist_id": self.artist_known_mb_id(variants),
            "similar": similar,
            "art_urls": [f"/api/song/{quote(fn)}/art" for fn in art_files],
            # Play-all seed (album/track order, same as the rows above).
            # Bounded so a pathological library can't balloon the payload.
            "files": fns[:1000],
        }

    def get_artist_enrichment(self, mb_artist_id: str) -> dict | None:
        """Cached artist-level enrichment row, JSON fields parsed (bad/legacy
        JSON degrades to empty rather than 500ing the links route)."""
        row = self.conn.execute(
            "SELECT mb_artist_id, url_rels, genres, fetched_at "
            "FROM artist_enrichment WHERE mb_artist_id = ?",
            (mb_artist_id,)).fetchone()
        if not row:
            return None

        def _parsed(raw, fallback):
            try:
                v = json.loads(raw) if raw else fallback
            except (TypeError, ValueError):
                return fallback
            return v if isinstance(v, type(fallback)) else fallback

        return {"mb_artist_id": row[0], "url_rels": _parsed(row[1], {}),
                "genres": _parsed(row[2], []), "fetched_at": row[3]}

    def put_artist_enrichment(self, mb_artist_id: str, url_rels: dict,
                              genres: list) -> None:
        """Store (or refresh) the one artist-level cache row."""
        with self._lock:
            self.conn.execute(
                "INSERT OR REPLACE INTO artist_enrichment "
                "(mb_artist_id, url_rels, genres, fetched_at) "
                "VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))",
                (mb_artist_id, json.dumps(url_rels or {}), json.dumps(genres or [])))
            self.conn.commit()

    def record_session(self, filename: str, arrangement: int, *, score: int,
                       accuracy: float, last_position=None) -> dict:
        """Record a scored play: plays += 1, best_* = max, last_* = new."""
        from song_score import merge_stats
        with self._lock:
            existing = self._stats_row(filename, int(arrangement))
            merged = merge_stats(existing, {
                "score": score, "accuracy": accuracy, "last_position": last_position,
            })
            self.conn.execute(
                """INSERT INTO song_stats
                       (filename, arrangement, plays, best_score, best_accuracy,
                        last_score, last_accuracy, last_position, last_played_at, updated_at)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?,
                           strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
                   ON CONFLICT(filename, arrangement) DO UPDATE SET
                       plays = excluded.plays,
                       best_score = excluded.best_score,
                       best_accuracy = excluded.best_accuracy,
                       last_score = excluded.last_score,
                       last_accuracy = excluded.last_accuracy,
                       last_position = excluded.last_position,
                       last_played_at = excluded.last_played_at,
                       updated_at = excluded.updated_at""",
                (filename, int(arrangement), merged["plays"], merged["best_score"],
                 merged["best_accuracy"], merged["last_score"], merged["last_accuracy"],
                 merged["last_position"]),
            )
            self.conn.commit()
        return self._stats_row(filename, int(arrangement))

    def touch_position(self, filename: str, arrangement: int, last_position: float) -> dict:
        """Persist just the resume position (no plays/score change), so
        Continue-Playing works for non-scored plays. Also stamps
        last_played_at — both /api/stats/recent and /api/session/continue
        filter/order on it, so a position-only touch must set it or the song
        never surfaces as 'recent' / 'continue playing'."""
        with self._lock:
            self.conn.execute(
                """INSERT INTO song_stats (filename, arrangement, last_position,
                                           last_played_at, updated_at)
                   VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
                           strftime('%Y-%m-%d %H:%M:%f','now'))
                   ON CONFLICT(filename, arrangement) DO UPDATE SET
                       last_position = excluded.last_position,
                       last_played_at = excluded.last_played_at,
                       updated_at = excluded.updated_at""",
                (filename, int(arrangement), float(last_position)),
            )
            self.conn.commit()
        return self._stats_row(filename, int(arrangement))

    def get_song_stats(self, filename: str) -> dict:
        """Best/last/plays across all arrangements of a song, plus per-arrangement rows."""
        rows = self.conn.execute(
            "SELECT " + ", ".join(self._STATS_COLS) +
            " FROM song_stats WHERE filename = ? ORDER BY arrangement",
            (filename,),
        ).fetchall()
        arr = [dict(zip(self._STATS_COLS, r)) for r in rows]
        best_acc = max((a["best_accuracy"] for a in arr), default=0.0)
        best_score = max((a["best_score"] for a in arr), default=0)
        plays = sum(a["plays"] for a in arr)
        return {
            "filename": filename,
            "best_accuracy": best_acc,
            "best_score": best_score,
            "plays": plays,
            "arrangements": arr,
        }

    def recent_stats(self, limit: int = 12) -> list[dict]:
        """Recently-played rows (most recent first) for 'Jump back in'."""
        limit = max(1, min(100, int(limit)))
        rows = self.conn.execute(
            "SELECT " + ", ".join(self._STATS_COLS) +
            " FROM song_stats WHERE last_played_at IS NOT NULL " +
            self._existing_song_filter() +
            "ORDER BY last_played_at DESC LIMIT ?",
            (limit,),
        ).fetchall()
        return [dict(zip(self._STATS_COLS, r)) for r in rows]

    def best_accuracy_map(self) -> dict:
        """{filename: best_accuracy} across all arrangements, for batch-badging
        the library grid in one request. Includes every SCORED song (plays > 0)
        — even a genuine 0% best — but excludes resume-only rows (plays == 0,
        which carry a default best_accuracy of 0 and shouldn't badge)."""
        rows = self.conn.execute(
            "SELECT filename, MAX(best_accuracy), SUM(plays) FROM song_stats "
            "WHERE 1=1 " + self._existing_song_filter() +   # skip dead songs (race-free)
            "GROUP BY filename"
        ).fetchall()
        return {r[0]: r[1] for r in rows if r[2] and r[2] > 0}

    def top_stats(self, limit: int = 5) -> list[dict]:
        """Top scored songs (best score first) for the profile 'Your best
        scores' panel. Aggregated per-song across arrangements (best score,
        best accuracy, total plays), only SCORED songs (plays > 0), dead songs
        skipped. Mirrors best_accuracy_map's grouping; enriched with metadata
        by the /api/stats/top route."""
        limit = max(1, min(50, int(limit)))
        rows = self.conn.execute(
            "SELECT filename, MAX(best_score), MAX(best_accuracy), SUM(plays) "
            "FROM song_stats WHERE 1=1 " + self._existing_song_filter() +   # skip dead songs
            "GROUP BY filename HAVING SUM(plays) > 0 "
            "ORDER BY MAX(best_score) DESC, MAX(best_accuracy) DESC LIMIT ?",
            (limit,),
        ).fetchall()
        return [
            {"filename": r[0], "best_score": r[1], "best_accuracy": r[2], "plays": r[3]}
            for r in rows
        ]

    # ── FUTURE ENHANCEMENT (revisit once the feedpak difficulty spec is locked) ──
    # The library-metadata design (§8) calls for user-difficulty to be
    # PER-ARRANGEMENT ("easy on bass ≠ easy on lead") and SEEDED FROM the authored/
    # derived difficulty so it's never blank. Neither ships here on purpose:
    #   • personal difficulty is currently per-FILENAME (P1's song_user_meta);
    #     per-arrangement is a P1-schema + Details-drawer (P2) re-scope; and
    #   • there is NO authored/derived difficulty field on `songs` yet — that waits
    #     on the feedpak difficulty spec (the #37-family FEP), which is unmerged.
    # So this recommender ships the growth-edge PAYOFF now and degrades gracefully
    # (an unrated song is treated as mid). When the feedpak difficulty field lands,
    # revisit: (1) seed unset user-difficulty from authored instead of assuming mid,
    # and (2) score per (filename, arrangement) rather than per song.
    @staticmethod
    def _growth_edge_score(best_accuracy: float, user_difficulty) -> float:
        """The 'practice next' score = difficulty-appropriateness × proximity to
        mastery. Peaks where a song is BOTH at a productive challenge level (the
        mid difficulty band) AND close to — but not yet at — mastery (the
        goal-gradient push). An UNSET personal difficulty is treated as mid, so
        the recommender still works before anything is rated (it degrades to
        closest-to-mastery-first) — see P3 notes: authored/derived difficulty
        seeding waits on the feedpak difficulty spec.

        diff_weight: 3 → 1.0, 2/4 → 0.8, 1/5 → 0.6 (extremes deprioritized, never
        zeroed — you grow on the challenging middle, not the trivially easy or the
        frustratingly hard). Never writes anything."""
        d = user_difficulty if user_difficulty is not None else 3
        weight = 1.0 - abs(d - 3) * 0.2
        return weight * (best_accuracy or 0.0)

    def growth_edge_suggestions(self, limit: int = 8) -> list[dict]:
        """Attempted-but-not-yet-mastered songs ranked by the growth-edge score —
        the 'Keep practicing' recommender that replaces recency-only ordering.
        Song-level (best accuracy across arrangements, like the badge); the
        suggested `arrangement` is the one you're closest to mastering, so the
        shelf opens the version worth pushing. Read-only."""
        limit = max(1, min(24, int(limit)))
        rows = self.conn.execute(
            "SELECT filename, arrangement, best_accuracy, plays, last_played_at "
            "FROM song_stats WHERE 1=1 " + self._existing_song_filter()
        ).fetchall()
        # Aggregate per song: best accuracy + the arrangement that owns it, total
        # plays, most-recent play (used as a stable tiebreak).
        agg: dict = {}
        for fn, arr, acc, plays, lp in rows:
            a = agg.get(fn)
            if a is None:
                a = agg[fn] = {"acc": None, "arr": 0, "plays": 0, "lp": None}
            a["plays"] += (plays or 0)
            if acc is not None and (a["acc"] is None or acc > a["acc"]):
                a["acc"] = acc
                a["arr"] = arr
            if lp and (not a["lp"] or lp > a["lp"]):
                a["lp"] = lp
        cands = [(fn, a) for fn, a in agg.items()
                 if a["plays"] > 0 and a["acc"] is not None and a["acc"] < MASTERY_ACCURACY]
        if not cands:
            # Two different empties (launch polish): attempts exist but
            # everything attempted is mastered → an empty shelf is honest;
            # NOTHING attempted yet (day one) → "starter" picks instead, so
            # the library home invites a first play rather than dead-ending.
            if any(a["plays"] > 0 and a["acc"] is not None for a in agg.values()):
                return []
            return self.starter_suggestions(limit)
        diffs = self.user_meta_map([fn for fn, _ in cands])   # {filename: 1..5}
        out = []
        for fn, a in cands:
            d = diffs.get(fn)
            out.append({
                "filename": fn,
                "best_accuracy": a["acc"],
                "arrangement": a["arr"],
                "last_played_at": a["lp"],
                "user_difficulty": d,
                "growth_score": round(self._growth_edge_score(a["acc"], d), 6),
            })
        out.sort(key=lambda r: (r["growth_score"], r["last_played_at"] or "", r["filename"]), reverse=True)
        return out[:limit]

    def starter_suggestions(self, limit: int = 8) -> list[dict]:
        """Day-one 'Start here' picks for a library with no practice attempts
        yet: up to 8 approachable songs — sensible length (90s–480s, so intros/
        jingles and 10-minute epics don't lead), shortest first, filename as a
        stable tiebreak. Same row shape as the growth-edge rows plus a
        `starter: true` marker so the client renders the invitational 'Start
        here' shelf instead of 'Keep practicing'. Read-only."""
        limit = max(1, min(8, int(limit)))
        rows = self.conn.execute(
            "SELECT filename FROM songs WHERE title != '' "
            "AND duration >= 90 AND duration <= 480 "
            "ORDER BY duration ASC, filename ASC LIMIT ?", (limit,)).fetchall()
        return [{
            "filename": r[0],
            "best_accuracy": None,
            "arrangement": None,
            "last_played_at": None,
            "user_difficulty": None,
            "growth_score": 0.0,
            "starter": True,
        } for r in rows]

    # ── Playlists ─────────────────────────────────────────────────────────--
    SAVED_KEY = "saved_for_later"

    def _playlist_count(self, pid: int, kind: str | None = None) -> int:
        # An ALBUM keeps every slot in its denominator: get_playlist renders /
        # plays ALL slots — self-healing orphans and even fully-missing works
        # (§7.2) stay visible — so the list-card count must agree with the detail
        # view and skip the dead-filter (is_album → no `AND s.filename IS NOT
        # NULL`, mirroring get_playlist). Mixes/other kinds count only songs that
        # still exist (mirrors the stats read-filter — dead songs are hidden, not
        # deleted on scan), passing through when the songs table is empty. Single
        # statement → no probe-then-read race. `kind` is passed by list_playlists
        # (already in hand); fetched here when a caller omits it.
        if kind is None:
            row = self.conn.execute(
                "SELECT kind FROM playlists WHERE id = ?", (pid,)
            ).fetchone()
            kind = row[0] if row else None
        if kind == "album":
            return self.conn.execute(
                "SELECT COUNT(*) FROM playlist_songs WHERE playlist_id = ?",
                (pid,),
            ).fetchone()[0]
        return self.conn.execute(
            "SELECT COUNT(*) FROM playlist_songs ps WHERE ps.playlist_id = ? "
            "AND EXISTS (SELECT 1 FROM songs s WHERE s.filename = ps.filename)",
            (pid,),
        ).fetchone()[0]

    def arrangement_count(self, filename: str):
        """Number of arrangements for a song, or None if the song isn't in the
        library (so callers can skip validation when it can't be checked)."""
        row = self.conn.execute("SELECT arrangements FROM songs WHERE filename = ?", (filename,)).fetchone()
        if not row or not row[0]:
            return None
        try:
            arr = json.loads(row[0])
        except (ValueError, TypeError):
            return None
        return len(arr) if isinstance(arr, list) else None

    def arrangement_entry(self, filename: str, index: int):
        """One arrangement's metadata dict for a library song, or None when
        the song/index is unknown (progression then falls back to guitar)."""
        row = self.conn.execute("SELECT arrangements FROM songs WHERE filename = ?", (filename,)).fetchone()
        if not row or not row[0]:
            return None
        try:
            arr = json.loads(row[0])
        except (ValueError, TypeError):
            return None
        if isinstance(arr, list) and 0 <= index < len(arr) and isinstance(arr[index], dict):
            return arr[index]
        return None

    def list_playlists(self) -> list[dict]:
        from urllib.parse import quote
        rows = self.conn.execute(
            "SELECT id, name, system_key, created_at, updated_at, kind FROM playlists "
            "WHERE rules IS NULL "          # smart collections live in the source picker, not here
            "ORDER BY (system_key IS NULL), name COLLATE NOCASE"
        ).fetchall()
        out = []
        for r in rows:
            pid = r[0]
            # First few still-present songs (in order) → art URLs, for a
            # content-dependent playlist cover (single art / 2x2 mosaic). The
            # JOIN drops dead songs, matching get_playlist's visibility.
            arts = self.conn.execute(
                "SELECT ps.filename FROM playlist_songs ps "
                "JOIN songs s ON s.filename = ps.filename "
                "WHERE ps.playlist_id = ? ORDER BY ps.position LIMIT 4",
                (pid,),
            ).fetchall()
            out.append({
                "id": pid, "name": r[1], "system_key": r[2],
                "created_at": r[3], "updated_at": r[4], "kind": r[5],
                "count": self._playlist_count(pid, r[5]),
                "art_urls": [f"/api/song/{quote(a[0])}/art" for a in arts],
            })
        return out

    def create_playlist(self, name: str, system_key: str | None = None,
                        kind: str | None = None) -> dict:
        with self._lock:
            cur = self.conn.execute(
                "INSERT INTO playlists (name, system_key, kind, created_at, updated_at) "
                "VALUES (?, ?, ?, datetime('now'), datetime('now'))",
                (name, system_key, kind),
            )
            self.conn.commit()
            pid = cur.lastrowid
        return self.get_playlist(pid)

    def saved_playlist_id(self) -> int:
        """Id of the reserved Saved-for-Later playlist, created on first use.
        Tolerates a create race: two concurrent first-use toggles can both see
        no row and try to insert; the unique system_key index makes the loser
        raise IntegrityError, so catch it and re-read the winner's row rather
        than 500."""
        row = self.conn.execute(
            "SELECT id FROM playlists WHERE system_key = ?", (self.SAVED_KEY,)
        ).fetchone()
        if row:
            return row[0]
        try:
            return self.create_playlist("Saved for Later", self.SAVED_KEY)["id"]
        except sqlite3.IntegrityError:
            row = self.conn.execute(
                "SELECT id FROM playlists WHERE system_key = ?", (self.SAVED_KEY,)
            ).fetchone()
            if row:
                return row[0]
            raise

    def rename_playlist(self, pid: int, name: str) -> bool:
        with self._lock:
            cur = self.conn.execute(
                "UPDATE playlists SET name = ?, updated_at = datetime('now') WHERE id = ?",
                (name, pid),
            )
            self.conn.commit()
            return cur.rowcount > 0

    def delete_playlist(self, pid: int) -> bool:
        """Delete a user playlist (system playlists are protected — caller checks)."""
        with self._lock:
            self.conn.execute("DELETE FROM playlist_songs WHERE playlist_id = ?", (pid,))
            cur = self.conn.execute("DELETE FROM playlists WHERE id = ?", (pid,))
            self.conn.commit()
            return cur.rowcount > 0

    # ── Smart collections (feedBack#636 item 2) ───────────────────────────
    @staticmethod
    def _collection_row(r) -> dict:
        rules = {}
        if r[3]:
            try:
                parsed = json.loads(r[3])
                if isinstance(parsed, dict):
                    rules = parsed
            except (ValueError, TypeError):
                rules = {}
        return {"id": r[0], "name": r[1], "system_key": r[2], "rules": rules,
                "created_at": r[4], "updated_at": r[5]}

    def is_collection(self, pid: int) -> bool:
        row = self.conn.execute(
            "SELECT rules IS NOT NULL FROM playlists WHERE id = ?", (pid,)
        ).fetchone()
        return bool(row and row[0])

    def list_collections(self) -> list[dict]:
        rows = self.conn.execute(
            "SELECT id, name, system_key, rules, created_at, updated_at FROM playlists "
            "WHERE rules IS NOT NULL ORDER BY name COLLATE NOCASE"
        ).fetchall()
        return [self._collection_row(r) for r in rows]

    def get_collection(self, pid: int) -> dict | None:
        r = self.conn.execute(
            "SELECT id, name, system_key, rules, created_at, updated_at FROM playlists "
            "WHERE id = ? AND rules IS NOT NULL", (pid,)
        ).fetchone()
        return self._collection_row(r) if r else None

    def create_collection(self, name: str, rules: dict) -> dict:
        with self._lock:
            cur = self.conn.execute(
                "INSERT INTO playlists (name, system_key, rules, created_at, updated_at) "
                "VALUES (?, NULL, ?, datetime('now'), datetime('now'))",
                (name, json.dumps(rules or {})),
            )
            self.conn.commit()
            pid = cur.lastrowid
        return self.get_collection(pid)

    def update_collection(self, pid: int, name: str | None = None,
                          rules: dict | None = None) -> dict | None:
        if not self.is_collection(pid):
            return None
        with self._lock:
            if name is not None:
                self.conn.execute("UPDATE playlists SET name = ? WHERE id = ?", (name, pid))
            if rules is not None:
                self.conn.execute("UPDATE playlists SET rules = ? WHERE id = ?",
                                  (json.dumps(rules or {}), pid))
            self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,))
            self.conn.commit()
        return self.get_collection(pid)

    def get_playlist(self, pid: int) -> dict | None:
        # A path-param int outside SQLite's 64-bit range raises OverflowError at
        # bind time (→ 500). Treat it as a miss; every mutating playlist handler
        # gates on this first, so the guard covers them too.
        if not isinstance(pid, int) or not (-(2**63) <= pid < 2**63):
            return None
        # `rules IS NULL` excludes smart collections (#636 item 2): they share
        # the playlists table but their membership is rules-based, so every
        # manual-playlist mutation (add/remove/reorder/cover) that gates on
        # get_playlist uniformly 404s on a collection id — collections are
        # managed only through /api/collections.
        head = self.conn.execute(
            "SELECT id, name, system_key, created_at, updated_at, kind FROM playlists "
            "WHERE id = ? AND rules IS NULL", (pid,)
        ).fetchone()
        if not head:
            return None
        is_album = head[5] == "album"
        # Mixes hide dead songs (race-free; not deleted on scan). An ALBUM keeps
        # every slot: a slot whose pinned chart was deleted self-heals to the
        # work's current preferred at READ (§7.2 orphan-at-play — never a
        # membership rewrite), and reports `missing` when the whole work is gone
        # so the practice set keeps its denominator visible.
        dead_filter = "" if is_album else "AND s.filename IS NOT NULL"
        rows = self.conn.execute(
            f"""SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name,
                       ps.arrangement, ps.work_key, s.arrangements,
                       (s.filename IS NULL) AS dead
               FROM playlist_songs ps LEFT JOIN songs s ON s.filename = ps.filename
               WHERE ps.playlist_id = ? {dead_filter}
               ORDER BY ps.position, ps.filename""",
            (pid,),
        ).fetchall()
        from urllib.parse import quote
        songs = []
        for r in rows:
            entry = {
                "filename": r[0], "position": r[1],
                "title": r[2] or r[0], "artist": r[3] or "", "tuning_name": r[4] or "",
                "art_url": f"/api/song/{quote(r[0])}/art",
            }
            if is_album:
                entry["arrangement"] = r[5]
                entry["work_key"] = r[6]
                try:
                    entry["arrangements"] = _ensure_smart_names(json.loads(r[7]) if r[7] else [])
                except Exception:
                    entry["arrangements"] = []
                if r[8]:
                    entry.update(self._resolve_album_orphan(r[6]))
            songs.append(entry)
        return {
            "id": head[0], "name": head[1], "system_key": head[2],
            "created_at": head[3], "updated_at": head[4], "songs": songs,
            **({"kind": head[5]} if head[5] else {}),
        }

    def _resolve_album_orphan(self, work_key: str | None) -> dict:
        """A deleted album slot resolves to its work's CURRENT preferred/auto
        pick at read (§7.2): the slot plays `resolved_filename` today, and if
        the pinned file reappears (rescan) it simply resolves back to itself —
        no rewrite in either direction. A work with no charts left reports
        `missing` (the row stays, dimmed, so the set's denominator is honest)."""
        if work_key:
            self._ensure_work_display()
            row = self.conn.execute(
                "SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements "
                "FROM work_display wd JOIN songs s ON s.filename = wd.filename "
                "WHERE wd.effective_work_key = ? AND wd.is_group_representative = 1",
                (work_key,)).fetchone()
            if row:
                from urllib.parse import quote
                try:
                    arrs = _ensure_smart_names(json.loads(row[4]) if row[4] else [])
                except Exception:
                    arrs = []
                return {"resolved_filename": row[0], "title": row[1] or row[0],
                        "artist": row[2] or "", "tuning_name": row[3] or "",
                        "arrangements": arrs,
                        "art_url": f"/api/song/{quote(row[0])}/art",
                        "resolved_from_orphan": True}
        return {"missing": True}

    def add_playlist_song(self, pid: int, filename: str):
        with self._lock:
            # Re-check existence INSIDE the lock: the handler's earlier 404 check
            # is a separate step, so a concurrent delete_playlist could land
            # between them and leave an orphan playlist_songs row. Returning None
            # lets the handler answer 404 instead of inserting an orphan.
            row = self.conn.execute("SELECT kind FROM playlists WHERE id = ?", (pid,)).fetchone()
            if not row:
                return None
            # Album slots stamp the work identity at ADD time (§7.2 "resolved to
            # preferred once at add, pinned thereafter") — it's what lets a
            # later-deleted chart's slot self-heal to the work's current keeper.
            wk = self.work_key_for(filename) if row[0] == "album" else None
            nxt = self.conn.execute(
                "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_songs WHERE playlist_id = ?", (pid,)
            ).fetchone()[0]
            cur = self.conn.execute(
                "INSERT OR IGNORE INTO playlist_songs (playlist_id, filename, position, work_key) "
                "VALUES (?, ?, ?, ?)",
                (pid, filename, nxt, wk),
            )
            self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,))
            self.conn.commit()
            return cur.rowcount > 0

    _SLOT_KEEP = object()   # sentinel: "leave the arrangement pin unchanged"

    def update_playlist_slot(self, pid: int, filename: str,
                             new_filename: str | None = None,
                             arrangement=_SLOT_KEEP):
        """Edit ONE album slot in place (§7.2): pin/clear its arrangement (a
        NAME — names survive rescans; None clears back to full-song) and/or swap
        the slot's chart for another chart of the SAME work, keeping position +
        pin — the per-slot pick is deliberately independent of the work's
        global preferred. Returns the slot's (possibly new) filename, or None
        when the slot doesn't exist, the swap target isn't a chart of the
        slot's work, or it's already in the playlist."""
        with self._lock:
            row = self.conn.execute(
                "SELECT position, work_key FROM playlist_songs "
                "WHERE playlist_id = ? AND filename = ?", (pid, filename)).fetchone()
            if not row:
                return None
            out_fn = filename
            if new_filename and new_filename != filename:
                # Same-work guard: the stored stamp wins (works even when the
                # pinned file is gone); fall back to computing from the row.
                wk_slot = row[1] or self.work_key_for(filename)
                if not wk_slot or self.work_key_for(new_filename) != wk_slot:
                    return None
                if self.conn.execute(
                        "SELECT 1 FROM playlist_songs WHERE playlist_id = ? AND filename = ?",
                        (pid, new_filename)).fetchone():
                    return None
                self.conn.execute(
                    "UPDATE playlist_songs SET filename = ?, work_key = ? "
                    "WHERE playlist_id = ? AND filename = ?",
                    (new_filename, wk_slot, pid, filename))
                out_fn = new_filename
            if arrangement is not self._SLOT_KEEP:
                self.conn.execute(
                    "UPDATE playlist_songs SET arrangement = ? "
                    "WHERE playlist_id = ? AND filename = ?",
                    (arrangement, pid, out_fn))
            self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,))
            self.conn.commit()
        return out_fn

    def remove_playlist_song(self, pid: int, filename: str) -> bool:
        with self._lock:
            cur = self.conn.execute(
                "DELETE FROM playlist_songs WHERE playlist_id = ? AND filename = ?", (pid, filename)
            )
            self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,))
            self.conn.commit()
            return cur.rowcount > 0

    def reorder_playlist(self, pid: int, ordered_filenames: list[str]) -> bool:
        with self._lock:
            for pos, fn in enumerate(ordered_filenames):
                self.conn.execute(
                    "UPDATE playlist_songs SET position = ? WHERE playlist_id = ? AND filename = ?",
                    (pos, pid, fn),
                )
            self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,))
            self.conn.commit()
        return True

    def toggle_saved(self, filename: str) -> bool:
        """Add/remove a song on the Saved-for-Later playlist. Returns new state.
        The presence check and the add/remove run under one lock so two
        concurrent toggles of the same song can't both take the add path (or
        both remove) and leave an inconsistent saved state."""
        pid = self.saved_playlist_id()
        with self._lock:
            present = self.conn.execute(
                "SELECT 1 FROM playlist_songs WHERE playlist_id = ? AND filename = ?", (pid, filename)
            ).fetchone() is not None
            if present:
                self.conn.execute(
                    "DELETE FROM playlist_songs WHERE playlist_id = ? AND filename = ?", (pid, filename))
                new_state = False
            else:
                nxt = self.conn.execute(
                    "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_songs WHERE playlist_id = ?", (pid,)
                ).fetchone()[0]
                self.conn.execute(
                    "INSERT OR IGNORE INTO playlist_songs (playlist_id, filename, position) VALUES (?, ?, ?)",
                    (pid, filename, nxt))
                new_state = True
            self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,))
            self.conn.commit()
        return new_state

    # ── Wishlist / "wanted" (feedBack#636 item 4) ─────────────────────────
    _WANTED_COLS = ("id", "artist", "title", "source", "source_ref", "note", "created_at")

    def add_wanted(self, artist: str, title: str, source: str = "manual",
                   source_ref: str = "", note: str = "") -> dict:
        """Add a not-owned song to the wishlist (or return the existing row if
        an entry with the same identity is already wanted — idempotent, so a
        re-run of an ownership-diff doesn't duplicate). Returns the row."""
        artist = (artist or "").strip()
        title = (title or "").strip()
        source = (source or "manual").strip() or "manual"
        source_ref = (source_ref or "").strip()
        note = (note or "").strip()
        with self._lock:
            self.conn.execute(
                "INSERT OR IGNORE INTO wanted (artist, title, source, source_ref, note, created_at) "
                "VALUES (?, ?, ?, ?, ?, datetime('now'))",
                (artist, title, source, source_ref, note),
            )
            row = self.conn.execute(
                "SELECT " + ", ".join(self._WANTED_COLS) + " FROM wanted "
                "WHERE artist = ? COLLATE NOCASE AND title = ? COLLATE NOCASE "
                "AND source = ? AND source_ref = ?",
                (artist, title, source, source_ref),
            ).fetchone()
            self.conn.commit()
        return dict(zip(self._WANTED_COLS, row)) if row else {}

    def list_wanted(self) -> list[dict]:
        """All wishlist entries, newest first."""
        rows = self.conn.execute(
            "SELECT " + ", ".join(self._WANTED_COLS) + " FROM wanted "
            "ORDER BY created_at DESC, id DESC"
        ).fetchall()
        return [dict(zip(self._WANTED_COLS, r)) for r in rows]

    def remove_wanted(self, wanted_id: int) -> bool:
        """Drop a wishlist entry by id. Returns True if a row was removed."""
        with self._lock:
            cur = self.conn.execute("DELETE FROM wanted WHERE id = ?", (wanted_id,))
            self.conn.commit()
            return cur.rowcount > 0

    def count_wanted(self) -> int:
        return self.conn.execute("SELECT COUNT(*) FROM wanted").fetchone()[0]

    def continue_session(self) -> dict | None:
        """Most-recently-played song (from song_stats) + metadata, for the
        Continue-Playing card. Null when nothing has been played."""
        row = self.conn.execute(
            "SELECT filename, arrangement, last_position FROM song_stats "
            "WHERE last_played_at IS NOT NULL " +
            self._existing_song_filter() +   # skip dead songs (race-free)
            "ORDER BY last_played_at DESC LIMIT 1"
        ).fetchone()
        if not row:
            return None
        filename, arrangement, last_position = row
        meta = self.conn.execute(
            "SELECT title, artist, tuning_name, duration FROM songs WHERE filename = ?", (filename,)
        ).fetchone()
        title, artist, tuning_name, duration = meta if meta else (None, None, None, None)
        from urllib.parse import quote
        return {
            "filename": filename, "arrangement": arrangement,
            "title": title or filename, "artist": artist or "",
            "tuning_name": tuning_name or "", "duration": duration or 0,
            "last_position": last_position,
            "art_url": f"/api/song/{quote(filename)}/art",
        }

    def favorite_set(self) -> set[str]:
        return {r[0] for r in self.conn.execute("SELECT filename FROM favorites").fetchall()}

    def get(self, filename: str, mtime: float, size: int) -> dict | None:
        cache_key = str(filename)
        with self._lock:
            row = self.conn.execute(
                "SELECT mtime, size, title, artist, album, year, duration, tuning, arrangements, has_lyrics, "
                "format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets "
                "FROM songs WHERE filename = ?", (cache_key,)
            ).fetchone()
        if row and row[0] == mtime and row[1] == size and row[2]:
            return {
                "title": row[2], "artist": row[3], "album": row[4],
                "year": row[5], "duration": row[6], "tuning": row[7],
                "arrangements": json.loads(row[8]) if row[8] else [],
                "has_lyrics": bool(row[9]),
                "format": row[10] or "archive",
                "stem_count": int(row[11] or 0),
                "stem_ids": json.loads(row[12]) if row[12] else [],
                "tuning_name": row[13] or "",
                "tuning_sort_key": int(row[14] or 0),
                "tuning_offsets": row[15] or "",
            }
        return None

    def put(self, filename: str, mtime: float, size: int, meta: dict):
        with self._lock:
            self.conn.execute(
                "INSERT OR REPLACE INTO songs "
                "(filename, mtime, size, title, artist, album, year, duration, tuning, arrangements, "
                "has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                (filename, mtime, size, meta.get("title", ""), meta.get("artist", ""),
                 meta.get("album", ""), meta.get("year", ""), meta.get("duration", 0),
                 meta.get("tuning", ""), json.dumps(meta.get("arrangements", [])),
                 1 if meta.get("has_lyrics") else 0,
                 meta.get("format", "archive"),
                 int(meta.get("stem_count", 0) or 0),
                 json.dumps(meta.get("stem_ids", []) or []),
                 meta.get("tuning_name", "") or "",
                 int(meta.get("tuning_sort_key", 0) or 0),
                 meta.get("tuning_offsets", "") or "",
                 meta.get("genre", "") or "",
                 meta.get("track_number"),
                 meta.get("disc")),
            )
            self.conn.commit()
            # A song's identity may have changed → the grouping read-model is stale.
            self._work_display_dirty = True

    def count(self) -> int:
        return self.conn.execute("SELECT COUNT(*) FROM songs WHERE title != ''").fetchone()[0]

    def delete_missing(self, current_filenames: set[str]):
        """Remove `songs` rows for files no longer on disk.

        Deliberately does NOT purge song_stats / playlist_songs here: a scan is a
        point-in-time snapshot, so a song that briefly disappears mid-scan (e.g.
        a directory-form .sloppak being overwritten via rmtree-then-extract, or a
        delete+reupload) and returns under the same filename would otherwise lose
        its stats/playlist membership permanently. Instead, stats are purged on
        the EXPLICIT delete path (DELETE /api/song) and dead-song rows are
        filtered at read time (recent_stats / continue_session /
        best_accuracy_map gate on the song still existing)."""
        with self._lock:
            db_files = {r[0] for r in self.conn.execute("SELECT filename FROM songs").fetchall()}
            stale = db_files - current_filenames
            if stale:
                self.conn.executemany("DELETE FROM songs WHERE filename = ?", [(f,) for f in stale])
                self.conn.commit()
                self._work_display_dirty = True   # membership changed → regroup
            # Report both deltas from the one query we already ran: rows pruned,
            # and how many current files are genuinely new (not yet in the DB),
            # so a scan can surface an "N added / M removed" summary.
            return {"removed": len(stale), "added": len(current_filenames - db_files)}

    # ── Metadata enrichment (P7 — plumbing; the matcher itself is the next
    # slice) ─────────────────────────────────────────────────────────────────

    @staticmethod
    def enrichment_content_hash(artist, title, album, duration) -> str:
        """Identity hash of the metadata a match keys on — normalized
        artist|title|album|duration. Deliberately excludes the filename, so a
        renamed pack keeps its enrichment (rename-survivable), and an unchanged
        hash makes re-enrichment a no-op (idempotent). Whitespace/case-folded
        so trivial edits don't invalidate a match; duration is rounded to whole
        seconds for the same reason."""
        def norm(s):
            return " ".join(str(s or "").lower().split())
        try:
            dur = str(int(round(float(duration or 0))))
        except (TypeError, ValueError):
            dur = "0"
        raw = "|".join([norm(artist), norm(title), norm(album), dur])
        return hashlib.sha1(raw.encode("utf-8")).hexdigest()

    def enrichment_pending(self, limit: int = 500,
                           allowed_keys: frozenset | None = None) -> list[dict]:
        """Songs whose enrichment row needs (re)matching: no row yet, or a
        row whose content_hash no longer matches the song's current metadata
        (an edit changed the identity → re-match), or an `unscanned` row.
        `manual` rows are the user's pinned pick and are NEVER re-queued.
        `matched`/`review`/`failed` rows with an UNCHANGED hash are settled
        here — a review row stands until the user acts, and a failed row
        retries only via the matcher's backoff policy (enrichment_failed_rows)
        rather than being re-queued every pass. An identity edit (say, the
        user fixes the typo that made matching fail) re-queues any of them
        immediately via the hash mismatch.

        `allowed_keys` is the set of per-field auto-apply toggle keys that are
        currently ON. A `matched` row stamped while one of those fields was
        suppressed (its key in `apply_mask`) is re-queued for backfill, so
        re-enabling a field honours the same "nothing forfeited" contract the
        source/art toggles already keep. None = don't apply the mask rule (the
        caller isn't the field-aware matcher, e.g. a plain count)."""
        # Read under _lock: the worker commits on this shared connection under
        # _lock, so an unlocked SELECT could interleave with its execute+commit.
        with self._lock:
            rows = self.conn.execute(
                "SELECT s.filename, s.artist, s.title, s.album, s.year, s.duration, "
                "e.content_hash, e.match_state, e.apply_mask "
                "FROM songs s LEFT JOIN song_enrichment e ON e.filename = s.filename "
                "WHERE s.title != '' AND (e.filename IS NULL "
                "OR e.match_state IN ('unscanned', 'matched', 'review', 'failed')) "
                "ORDER BY s.filename LIMIT ?", (max(1, int(limit)),)).fetchall()
        out = []
        for fn, artist, title, album, year, duration, ehash, state, mask in rows:
            h = self.enrichment_content_hash(artist, title, album, duration)
            # No row yet, still unmatched, or the identity changed under a
            # settled row → needs the matcher. A settled row with an
            # unchanged hash stays settled (idempotence)…
            needs = state is None or state == "unscanned" or ehash != h
            # …EXCEPT a `matched` row that suppressed a field now re-enabled:
            # re-queue it so the newly-allowed field gets backfilled.
            if not needs and state == "matched" and allowed_keys is not None and mask:
                if {k for k in mask.split(",") if k} & allowed_keys:
                    needs = True
            if needs:
                out.append({"filename": fn, "artist": artist, "title": title,
                            "album": album, "year": year, "duration": duration,
                            "content_hash": h, "match_state": state})
        return out

    def upsert_enrichment_stub(self, filename: str, content_hash: str) -> None:
        """Write/refresh a row's identity hash ahead of matching. A row whose
        hash changed drops back to `unscanned` (the old match no longer applies)
        — EXCEPT a `manual` row, which is the user's explicit pick and survives
        metadata edits untouched."""
        with self._lock:
            # Idempotence: skip the UPDATE/commit when the upsert would be a
            # no-op. The no-op matcher (P7) re-stamps every pending row each
            # pass; without this guard an already-settled row would be
            # rewritten every ~5 min, N commits/pass contending with request
            # writes. A `manual` pick never changes here, and a non-manual row
            # whose hash already matches keeps its state+hash — both no-ops.
            cur = self.conn.execute(
                "SELECT content_hash, match_state FROM song_enrichment WHERE filename = ?",
                (filename,)).fetchone()
            if cur is not None:
                old_hash, state = cur
                if state == "manual" or old_hash == content_hash:
                    return
            self.conn.execute(
                "INSERT INTO song_enrichment (filename, content_hash, match_state) "
                "VALUES (?, ?, 'unscanned') "
                "ON CONFLICT(filename) DO UPDATE SET "
                "  match_state = CASE WHEN song_enrichment.match_state = 'manual' "
                "                     THEN song_enrichment.match_state "
                "                     WHEN song_enrichment.content_hash IS NOT excluded.content_hash "
                "                     THEN 'unscanned' "
                "                     ELSE song_enrichment.match_state END, "
                # An identity change restarts the failure backoff too — the
                # accumulated attempts belonged to the OLD identity (e.g. the
                # user just fixed the typo that made matching fail).
                "  attempts = CASE WHEN song_enrichment.match_state = 'manual' "
                "                  THEN song_enrichment.attempts "
                "                  WHEN song_enrichment.content_hash IS NOT excluded.content_hash "
                "                  THEN 0 "
                "                  ELSE song_enrichment.attempts END, "
                "  content_hash = CASE WHEN song_enrichment.match_state = 'manual' "
                "                      THEN song_enrichment.content_hash "
                "                      ELSE excluded.content_hash END",
                (filename, content_hash))
            self.conn.commit()

    def get_enrichment(self, filename: str) -> dict | None:
        # Read under _lock (shared write connection — see enrichment_pending).
        with self._lock:
            row = self.conn.execute(
                "SELECT filename, content_hash, match_state, match_source, match_score, attempts, "
                "mb_recording_id, mb_release_id, mb_artist_id, isrc, "
                "canon_artist, canon_album, canon_title, canon_year, canon_artist_sort, "
                "genres, art_cache_path, art_state, fetched_at, candidates, last_attempt_at, "
                "apply_mask "
                "FROM song_enrichment WHERE filename = ?", (filename,)).fetchone()
        if not row:
            return None
        keys = ("filename", "content_hash", "match_state", "match_source", "match_score",
                "attempts", "mb_recording_id", "mb_release_id", "mb_artist_id", "isrc",
                "canon_artist", "canon_album", "canon_title", "canon_year",
                "canon_artist_sort", "genres", "art_cache_path", "art_state", "fetched_at",
                "candidates", "last_attempt_at", "apply_mask")
        out = dict(zip(keys, row))
        for k in ("genres", "candidates"):
            try:
                out[k] = json.loads(out[k]) if out[k] else []
            except (ValueError, TypeError):
                out[k] = []
        return out

    def enrichment_state_counts(self) -> dict:
        """{match_state: count} over rows whose song still exists (dead rows are
        filtered at read time, matching the never-purged-on-rescan contract)."""
        # Read under _lock (shared write connection — see enrichment_pending).
        with self._lock:
            rows = self.conn.execute(
                "SELECT e.match_state, COUNT(*) FROM song_enrichment e "
                "JOIN songs s ON s.filename = e.filename GROUP BY e.match_state").fetchall()
        return {r[0]: r[1] for r in rows}

    def enrichment_states_for(self, filenames: list[str]) -> dict:
        """{filename: match_state} for the given songs — a never-enriched (or
        unknown) filename is simply absent from the result. Powers the per-tile
        badges on the "Refresh Metadata" batch: the grid polls only the
        filenames in its visible window, not the whole library, so a card can
        animate queued→working→result without a per-song round-trip."""
        if not filenames:
            return {}
        out: dict = {}
        with self._lock:
            # Chunk under SQLite's variable limit so a huge visible window (or a
            # hostile caller) can't overflow the single IN (...) parameter list.
            for i in range(0, len(filenames), 400):
                chunk = filenames[i:i + 400]
                q = ("SELECT filename, match_state FROM song_enrichment "
                     "WHERE filename IN (%s)" % ",".join("?" * len(chunk)))
                for fn, st in self.conn.execute(q, chunk).fetchall():
                    out[fn] = st
        return out

    def _unmatched_set(self, filenames) -> set:
        """The subset of `filenames` whose enrichment landed in the 'failed'
        (no-match) state — feeds the grid's persistent per-card "no match" badge,
        so the misses stay visible at rest (the batch tile only shows while a
        refresh runs). Chunked set membership, like favorite_set."""
        fns = list(filenames)
        out: set = set()
        for i in range(0, len(fns), 400):
            chunk = fns[i:i + 400]
            if not chunk:
                break
            q = ("SELECT filename FROM song_enrichment WHERE match_state = 'failed' "
                 "AND filename IN (%s)" % ",".join("?" * len(chunk)))
            out.update(r[0] for r in self.conn.execute(q, chunk).fetchall())
        return out

    def enrichment_song_row(self, filename: str) -> dict | None:
        """The identity fields the matcher/scorer keys on, for one song."""
        row = self.conn.execute(
            "SELECT filename, artist, title, album, year, duration "
            "FROM songs WHERE filename = ?", (filename,)).fetchone()
        if not row:
            return None
        return dict(zip(("filename", "artist", "title", "album", "year", "duration"), row))

    def enrichment_failed_rows(self, limit: int = 500) -> list[dict]:
        """`failed` rows that MAY retry, with the fields the backoff policy
        (worker-side) needs to decide eligibility. `rejected` rows are the
        user's explicit "none of these" — never auto-retried (an identity
        edit re-queues them through enrichment_pending's hash mismatch
        instead)."""
        rows = self.conn.execute(
            "SELECT s.filename, s.artist, s.title, s.album, s.year, s.duration, "
            "e.attempts, e.last_attempt_at "
            "FROM songs s JOIN song_enrichment e ON e.filename = s.filename "
            "WHERE s.title != '' AND e.match_state = 'failed' "
            "AND COALESCE(e.match_source, '') != 'rejected' "
            "ORDER BY s.filename LIMIT ?", (max(1, int(limit)),)).fetchall()
        out = []
        for fn, artist, title, album, year, duration, attempts, last_at in rows:
            out.append({"filename": fn, "artist": artist, "title": title,
                        "album": album, "year": year, "duration": duration,
                        "content_hash": self.enrichment_content_hash(artist, title, album, duration),
                        "attempts": attempts or 0, "last_attempt_at": last_at})
        return out

    def enrichment_cache_lookup(self, content_hash: str, exclude_filename: str = "") -> dict | None:
        """A settled match for the same identity hash — another chart of the
        same recording already matched/pinned → copy it, no network (design
        §5 step 1: the local match-cache). Only FULLY-applied donors qualify
        (apply_mask empty/NULL): a row that suppressed a display field under an
        auto-apply toggle would otherwise seed siblings with its blanks even
        when the reader's own toggles want that field — so a partial row is
        skipped and the sibling falls through to its own (re-filtered) match."""
        row = self.conn.execute(
            "SELECT match_score, mb_recording_id, mb_release_id, mb_artist_id, isrc, "
            "canon_artist, canon_album, canon_title, canon_year, canon_artist_sort, genres "
            "FROM song_enrichment WHERE content_hash = ? AND filename != ? "
            "AND match_state IN ('matched', 'manual') AND mb_recording_id IS NOT NULL "
            "AND COALESCE(apply_mask, '') = '' "
            "LIMIT 1", (content_hash, exclude_filename or "")).fetchone()
        if not row:
            return None
        try:
            genres = json.loads(row[10]) if row[10] else []
        except (ValueError, TypeError):
            genres = []
        return {
            "score": row[0],
            "recording_id": row[1], "release_id": row[2] or "", "artist_id": row[3] or "",
            "isrc": row[4] or "", "artist": row[5] or "", "album": row[6] or "",
            "title": row[7] or "", "year": row[8] or "", "artist_sort": row[9] or "",
            "genres": genres,
        }

    def apply_enrichment_match(self, filename: str, content_hash: str, state: str,
                               source: str | None = None, score: float | None = None,
                               cand: dict | None = None, candidates: list | None = None,
                               bump_attempts: bool = False,
                               allow_manual_overwrite: bool = False,
                               apply_mask: str | None = None) -> bool:
        """The single writer for every matcher/review outcome. Writes the
        full lifecycle row: state + source + score, the canonical fields a
        confident match supplies (`cand`), and/or the review tier's ranked
        `candidates`. Returns False without touching anything when the row is
        `manual` and the caller isn't explicitly acting for the user — the
        never-overwrite-manual contract lives HERE so no future call path
        can forget it. Art-cache fields are preserved verbatim (they belong
        to the art slice, not the matcher). `apply_mask` (blocked per-field
        keys, from the matcher) is stamped verbatim so enrichment_pending /
        enrichment_cache_lookup can tell a fully-applied match from a
        field-suppressed one; the review/manual writers leave it NULL (a
        confirmed pick applies in full)."""
        cand = cand or {}
        now = time.time()
        with self._lock:
            cur = self.conn.execute(
                "SELECT match_state, attempts, art_cache_path, art_state, fetched_at "
                "FROM song_enrichment WHERE filename = ?", (filename,)).fetchone()
            if cur and cur[0] == "manual" and not allow_manual_overwrite:
                return False
            # An explicit reset to `unscanned` (Refresh metadata) is a fresh
            # start — the failure backoff restarts with the identity, same as
            # the stub upsert's hash-change rule.
            attempts = 0 if state == "unscanned" else (int(cur[1] or 0) if cur else 0)
            if bump_attempts:
                attempts += 1
            fetched_at = (time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
                          if state in ("matched", "manual", "review")
                          else (cur[4] if cur else None))
            self.conn.execute(
                "INSERT OR REPLACE INTO song_enrichment (filename, content_hash, "
                "match_state, match_source, match_score, attempts, "
                "mb_recording_id, mb_release_id, mb_artist_id, isrc, "
                "canon_artist, canon_album, canon_title, canon_year, canon_artist_sort, "
                "genres, art_cache_path, art_state, fetched_at, candidates, last_attempt_at, "
                "apply_mask) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                (filename, content_hash, state, source, score, attempts,
                 cand.get("recording_id") or None, cand.get("release_id") or None,
                 cand.get("artist_id") or None, cand.get("isrc") or None,
                 cand.get("artist") or None, cand.get("album") or None,
                 cand.get("title") or None, cand.get("year") or None,
                 cand.get("artist_sort") or None,
                 json.dumps(cand.get("genres") or []) if cand else "[]",
                 cur[2] if cur else None, cur[3] if cur else None,
                 fetched_at,
                 json.dumps(candidates) if candidates else None,
                 now if state == "failed" else None,
                 apply_mask or None))
            self.conn.commit()
        return True

    def set_enrichment_manual(self, filename: str, cand: dict, source: str = "search") -> bool:
        """User-pinned match (review Accept / manual search-and-pick). The
        highest-authority state: never auto-reset, survives identity edits.
        `source` records HOW it was pinned ('review' = accepted a proposed
        candidate, 'search' = picked from a manual search)."""
        song = self.enrichment_song_row(filename)
        if not song:
            return False
        h = self.enrichment_content_hash(
            song["artist"], song["title"], song["album"], song["duration"])
        return self.apply_enrichment_match(
            filename, h, "manual", source=source, score=1.0, cand=cand,
            allow_manual_overwrite=True)

    def set_enrichment_rejected(self, filename: str) -> bool:
        """User said "none of these candidates" — clear any canonical values
        and park the row as failed/rejected (never auto-retried; an identity
        edit re-queues it). Refused for `manual` rows: un-pinning a pick the
        user explicitly made is not a review-drawer action."""
        row = self.get_enrichment(filename)
        if not row or row["match_state"] not in ("review", "matched"):
            return False
        return self.apply_enrichment_match(
            filename, row["content_hash"], "failed", source="rejected",
            score=None, candidates=row.get("candidates") or None)

    def enrichment_review_queue(self, limit: int = 200,
                                order: str = "missing_first") -> list[dict]:
        """The Match-Review drawer's queue: review-tier rows joined to their
        (still-existing) songs, with the stored candidate list parsed.
        `order` is the user's review-queue preference: 'missing_first'
        (default — charts missing album/year surface first, they gain the
        most from a confirm; complete charts only stand to be re-labelled),
        'artist' (A–Z), or 'recent' (newest files first). Unknown values
        fall back to missing_first."""
        order_sql = {
            "artist": "s.artist COLLATE NOCASE, s.title COLLATE NOCASE, e.filename",
            "recent": "s.mtime DESC, e.filename",
        }.get(order, "((COALESCE(s.album, '') = '') + (COALESCE(s.year, '') = '')) DESC, "
                     "s.artist COLLATE NOCASE, s.title COLLATE NOCASE, e.filename")
        rows = self.conn.execute(
            "SELECT e.filename, s.title, s.artist, s.album, s.year, s.duration, s.mtime, "
            "e.match_score, e.candidates, e.attempts "
            "FROM song_enrichment e JOIN songs s ON s.filename = e.filename "
            "WHERE e.match_state = 'review' "
            "ORDER BY " + order_sql + " "
            "LIMIT ?", (max(1, int(limit)),)).fetchall()
        out = []
        for fn, title, artist, album, year, duration, mtime, score, cands, attempts in rows:
            try:
                candidates = json.loads(cands) if cands else []
            except (ValueError, TypeError):
                candidates = []
            out.append({"filename": fn, "title": title, "artist": artist,
                        "album": album, "year": year, "duration": duration,
                        "mtime": mtime, "match_score": score,
                        "candidates": candidates, "attempts": attempts or 0})
        return out

    def enrichment_art_pending(self, limit: int = 500) -> list[dict]:
        """Matched songs whose cover-art situation hasn't been evaluated yet
        (art_state NULL). The art worker resolves each to 'pack' (song has its
        own art), 'user' (an override exists), 'caa' (fetched), 'none' (the
        release has no cover) or 'error' — any of which settles the row, so
        this never re-offers a song each pass."""
        rows = self.conn.execute(
            "SELECT e.filename, e.mb_release_id "
            "FROM song_enrichment e JOIN songs s ON s.filename = e.filename "
            "WHERE e.match_state IN ('matched', 'manual') "
            "AND e.mb_release_id IS NOT NULL AND e.art_state IS NULL "
            "ORDER BY e.filename LIMIT ?", (max(1, int(limit)),)).fetchall()
        return [{"filename": r[0], "mb_release_id": r[1]} for r in rows]

    def set_enrichment_art(self, filename: str, path: str | None, state: str | None) -> None:
        """Stamp a row's art-cache outcome. Targeted UPDATE (not the match
        writer) so it can never disturb the match lifecycle fields."""
        with self._lock:
            self.conn.execute(
                "UPDATE song_enrichment SET art_cache_path = ?, art_state = ? "
                "WHERE filename = ?", (path, state, filename))
            self.conn.commit()

    def clear_enrichment_art_paths(self, paths: list[str]) -> None:
        """Reset rows whose cached art file was evicted (LRU prune) back to
        unevaluated, so a later pass may re-fetch if the song still qualifies."""
        if not paths:
            return
        with self._lock:
            ph = ",".join("?" * len(paths))
            self.conn.execute(
                f"UPDATE song_enrichment SET art_cache_path = NULL, art_state = NULL "
                f"WHERE art_cache_path IN ({ph})", paths)
            self.conn.commit()

    def _estd_set(self) -> set[str]:
        """Get set of filenames that have a retuned variant (_EStd_ or _DropD_) in the DB."""
        rows = self.conn.execute(
            "SELECT filename FROM songs WHERE filename LIKE '%\\_EStd\\_%' ESCAPE '\\' "
            "OR filename LIKE '%\\_DropD\\_%' ESCAPE '\\'"
        ).fetchall()
        originals = set()
        for (fname,) in rows:
            originals.add(fname.replace("_EStd_", "_").replace("_DropD_", "_"))
        return originals

    # Manifest-allowed filter values. Whitelisted before binding so a
    # malformed query string can't push arbitrary text through to SQL —
    # parameters are bound, but capping the input space is still cheap
    # defense-in-depth (see feedBack#129).
    _ALLOWED_ARRANGEMENT_NAMES = {"Lead", "Rhythm", "Bass", "Combo"}
    # Per-smart-type list of (sql_op, sql_param) pairs appended to the SQL
    # name-fallback branch (key-absent smart_name). Covers legacy raw names
    # and load_song()'s synthesised display names that map to each smart type.
    _SMART_NULL_FALLBACK_EXTRAS: dict[str, tuple[tuple[str, str], ...]] = {
        "Lead": (("=", "Combo"), ("LIKE", "Alt. Combo%"), ("LIKE", "Bonus Combo%")),
        "Bass": (("=", "Bass 2"),),
    }
    # Stem ids match the bare strings sloppak manifests use today —
    # `full`, `guitar`, `bass`, `drums`, `vocals`, `piano`, `other`. The
    # frontend filter UI omits `full` (it's the always-on fallback mix
    # and would match every sloppak), but the server-side whitelist
    # keeps it so a hand-rolled API client can still ask for it.
    _ALLOWED_STEM_IDS = {"full", "guitar", "bass", "drums", "vocals", "piano", "other"}

    @classmethod
    def _smart_null_extras(cls, arr_type: str) -> tuple[str, list[str]]:
        """Return (sql_fragment, bound_params) for the extra raw-name terms to
        OR into the key-absent NULL-smart_name fallback branch for arr_type.
        Empty when no extras are defined."""
        terms = cls._SMART_NULL_FALLBACK_EXTRAS.get(arr_type, ())
        fragment = "".join(
            f" OR json_extract(value, '$.name') {op} ?" for op, _ in terms
        )
        return fragment, [val for _, val in terms]

    def _build_where(self, q: str = "", favorites_only: bool = False,
                     format_filter: str = "",
                     artist_filter: str = "",
                     album_filter: str = "",
                     arrangements_has: list[str] | None = None,
                     arrangements_lacks: list[str] | None = None,
                     stems_has: list[str] | None = None,
                     stems_lacks: list[str] | None = None,
                     has_lyrics: int | None = None,
                     tunings: list[str] | None = None,
                     mastery: list[str] | None = None,
                     tags_has: list[str] | None = None,
                     user_difficulty_in: list[str] | None = None,
                     match_states: list[str] | None = None,
                     genre: list[str] | None = None,
                     naming_mode: str = "legacy",
                     include_intrinsic: bool = True) -> tuple[str, list]:
        """Shared WHERE-clause builder for query_page / query_artists /
        query_stats. Returns (where_sql, params). Leading 'WHERE' is
        included so callers paste it directly. See feedBack#129/#69.

        Clauses are two classes (the §7.1 filter law): work-identity +
        practice-state predicates live here; CHART-INTRINSIC predicates
        (format / arrangements / stems / lyrics / tuning) are built by
        `_build_intrinsic_where` and appended when `include_intrinsic`.
        Grouped queries pass include_intrinsic=False and re-apply the
        intrinsic set as a match-if-ANY-member subquery instead.
        """
        where = "WHERE title != ''"
        params: list = []
        if favorites_only:
            where += " AND filename IN (SELECT filename FROM favorites)"
        if artist_filter:
            # The dropdown/tree list CANONICAL names (query_artists), so a filter
            # value is canonical — expand it to every raw variant aliased to it so
            # picking "AC/DC" returns songs tagged "ACDC" too. `artist IN (...)`
            # keeps the artist index (keyset-safe), unlike a per-row COALESCE.
            variants = self._raw_variants_for(artist_filter)
            ph = ",".join(["?"] * len(variants))
            where += f" AND artist COLLATE NOCASE IN ({ph})"
            params += variants
        if album_filter:
            where += " AND album = ? COLLATE NOCASE"
            params.append(album_filter)
        # Genre facet (primary genre column, populated from the feedpak `genres`
        # list on scan). OR within the selected set.
        if genre:
            _gph = ",".join(["?"] * len(genre))
            where += f" AND ({self._effective_genre_expr()}) COLLATE NOCASE IN ({_gph})"
            params += list(genre)
        # Mastery bands = best accuracy across a song's arrangements (song_stats,
        # a separate table -> correlated subquery). mastered >= 0.9, in_progress =
        # attempted but < 0.9, not_started = no score. OR within the selected set.
        if mastery:
            _msub = "(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename)"
            _bands = {
                "mastered": f"{_msub} >= 0.9",
                "in_progress": f"({_msub} IS NOT NULL AND {_msub} < 0.9)",
                "not_started": f"{_msub} IS NULL",
            }
            _sel = [_bands[b] for b in mastery if b in _bands]
            if _sel:
                where += " AND (" + " OR ".join(_sel) + ")"
        # Personal practice tags (song_tags) — any-of. EXISTS-style IN keeps it a
        # predicate on `songs` (keyset-safe, no row multiplication). Normalized to
        # match how tags are stored.
        _tags = [t for t in (_normalize_tag(x) for x in (tags_has or [])) if t]
        if _tags:
            ph = ",".join(["?"] * len(_tags))
            where += (" AND filename IN (SELECT filename FROM song_tags "
                      f"WHERE tag IN ({ph}))")
            params += _tags
        # Personal user-difficulty (song_user_meta) — any-of over the 1..5 set.
        _diffs = []
        for d in (user_difficulty_in or []):
            try:
                di = int(d)
            except (TypeError, ValueError):
                continue
            if 1 <= di <= 5:
                _diffs.append(di)
        if _diffs:
            ph = ",".join(["?"] * len(_diffs))
            where += (" AND filename IN (SELECT filename FROM song_user_meta "
                      f"WHERE user_difficulty IN ({ph}))")
            params += _diffs
        # Match facet (P8) = the song's enrichment lifecycle state, from the
        # separate song_enrichment table (same EXISTS idiom as mastery above).
        # 'matched' folds in 'manual' (a user pin IS a match); 'pending' means
        # no verdict yet (no row, or still unscanned). OR within the set.
        if match_states:
            _esub = "SELECT 1 FROM song_enrichment e WHERE e.filename = songs.filename"
            _mstates = {
                "review": f"EXISTS ({_esub} AND e.match_state = 'review')",
                "matched": f"EXISTS ({_esub} AND e.match_state IN ('matched', 'manual'))",
                "unmatched": f"EXISTS ({_esub} AND e.match_state = 'failed')",
                "pending": f"NOT EXISTS ({_esub} AND e.match_state != 'unscanned')",
            }
            _msel = [_mstates[b] for b in match_states if b in _mstates]
            if _msel:
                where += " AND (" + " OR ".join(_msel) + ")"
        if q:
            _qlike = f"%{q}%"
            _qterms = ("title LIKE ? COLLATE NOCASE OR artist LIKE ? COLLATE NOCASE "
                       "OR album LIKE ? COLLATE NOCASE")
            _qparams = [_qlike] * 3
            # Alias-aware artist term (launch polish): searching the CANONICAL
            # name ("AC/DC") must also find songs whose raw tag is a merged
            # variant ("ACDC") — expand via the artist_alias table. Pure
            # predicate (keyset-safe); probe-guarded so the common no-aliases
            # library keeps the exact original 3-term query.
            if self.conn.execute("SELECT 1 FROM artist_alias LIMIT 1").fetchone() is not None:
                _qterms += (" OR artist COLLATE NOCASE IN (SELECT raw_name FROM artist_alias "
                            "WHERE canonical_name LIKE ? COLLATE NOCASE)")
                _qparams.append(_qlike)
            where += f" AND ({_qterms})"
            params += _qparams
        if include_intrinsic:
            ifrag, iparams = self._build_intrinsic_where(
                "songs", format_filter=format_filter,
                arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
                stems_has=stems_has, stems_lacks=stems_lacks,
                has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
            where += ifrag
            params += iparams
        return where, params

    def _build_intrinsic_where(self, alias: str, format_filter: str = "",
                               arrangements_has: list[str] | None = None,
                               arrangements_lacks: list[str] | None = None,
                               stems_has: list[str] | None = None,
                               stems_lacks: list[str] | None = None,
                               has_lyrics: int | None = None,
                               tunings: list[str] | None = None,
                               naming_mode: str = "legacy") -> tuple[str, list]:
        """CHART-INTRINSIC predicates (format / arrangements / stems / lyrics /
        tuning) as ' AND …' fragments against an explicit table alias. Flat
        queries apply them to `songs` directly; grouped queries evaluate them
        against each work member `m` inside an EXISTS (§7.1 filter law — a
        work matches when ANY of its charts does, so a song you own in Drop D
        isn't hidden because your preferred chart is E Standard)."""
        where = ""
        params: list = []
        if format_filter:
            where += f" AND {alias}.format = ?"
            params.append(format_filter)
        # arrangements_has / arrangements_lacks: OR within axis (any-of).
        # Uses JSON1's json_each which yields one row per arrangement, then
        # matches the relevant field. The whole subquery is wrapped in EXISTS
        # so we don't multiply rows in the outer SELECT.
        #
        # Smart mode: each requested type (Lead/Rhythm/Bass) matches against
        # smart_name when present. "Lead" matches smart_name in
        # ('Lead', 'Alt. Lead', 'Alt. Lead N', 'Bonus Lead', 'Bonus Lead N').
        # Falls back to matching `name` for older rows without smart_name.
        # Legacy mode: matches `name` directly (original behaviour).
        arr_has = [a for a in (arrangements_has or []) if a in self._ALLOWED_ARRANGEMENT_NAMES]
        if arr_has and naming_mode == "smart":
            # Smart mode subsumes "Combo" into "Lead" — normalize here so a
            # hand-rolled API client matches the client-side behaviour and
            # the SQL doesn't need a "Combo" smart-type branch.
            arr_has = list(dict.fromkeys("Lead" if a == "Combo" else a for a in arr_has))
        if arr_has:
            if naming_mode == "smart":
                clauses = []
                for arr_type in arr_has:
                    # Extra raw-name fragments matched only in the key-absent
                    # NULL-smart_name fallback branch — they cover the legacy
                    # display names that map to this smart type:
                    #   Lead: "Combo" (combined guitar) + Alt./Bonus Combo
                    #   Bass: "Bass 2" (load_song synthesises for real_bass_22)
                    extra_null, extra_null_params = self._smart_null_extras(arr_type)
                    # json_type() returns NULL when the key is absent and the
                    # string 'null' when the key exists with explicit JSON null
                    # (set by the scanner for ambiguous duplicate-name rows).
                    # Name-fallback only applies to key-absent rows so an
                    # explicit null suppresses the fallback and lets the
                    # background rescan resolve the ambiguity authoritatively.
                    clauses.append(
                        "(json_extract(value, '$.smart_name') IS NOT NULL AND ("
                        f"json_extract(value, '$.smart_name') = ? OR "
                        f"json_extract(value, '$.smart_name') LIKE ? OR "
                        f"json_extract(value, '$.smart_name') LIKE ?"
                        ")) OR ("
                        "json_type(value, '$.smart_name') IS NULL AND ("
                        "json_extract(value, '$.name') = ? OR "
                        "json_extract(value, '$.name') LIKE ? OR "
                        f"json_extract(value, '$.name') LIKE ?{extra_null}))"
                    )
                    params += [
                        arr_type,
                        f"Alt. {arr_type}%",
                        f"Bonus {arr_type}%",
                        arr_type,
                        f"Alt. {arr_type}%",
                        f"Bonus {arr_type}%",
                    ] + extra_null_params
                where += (
                    f" AND EXISTS (SELECT 1 FROM json_each({alias}.arrangements) WHERE "
                    + " OR ".join(f"({c})" for c in clauses)
                    + ")"
                )
            else:
                placeholders = ",".join(["?"] * len(arr_has))
                where += (f" AND EXISTS (SELECT 1 FROM json_each({alias}.arrangements) "
                          f"WHERE json_extract(value, '$.name') IN ({placeholders}))")
                params += arr_has
        arr_lacks = [a for a in (arrangements_lacks or []) if a in self._ALLOWED_ARRANGEMENT_NAMES]
        if arr_lacks and naming_mode == "smart":
            arr_lacks = list(dict.fromkeys("Lead" if a == "Combo" else a for a in arr_lacks))
        if arr_lacks:
            if naming_mode == "smart":
                clauses = []
                for arr_type in arr_lacks:
                    extra_null, extra_null_params = self._smart_null_extras(arr_type)
                    # See "has" branch above for the json_type rationale.
                    # Extra branch (vs `has`): an explicit smart_name=null
                    # arrangement is ambiguous; we don't know whether it's
                    # `arr_type` or not. Be conservative and treat it as
                    # potentially matching, so `arrangements_lacks` excludes
                    # the parent row instead of falsely claiming it lacks
                    # `arr_type`. The background rescan resolves the ambiguity.
                    clauses.append(
                        "(json_extract(value, '$.smart_name') IS NOT NULL AND ("
                        f"json_extract(value, '$.smart_name') = ? OR "
                        f"json_extract(value, '$.smart_name') LIKE ? OR "
                        f"json_extract(value, '$.smart_name') LIKE ?"
                        ")) OR ("
                        "json_type(value, '$.smart_name') = 'null'"
                        ") OR ("
                        "json_type(value, '$.smart_name') IS NULL AND ("
                        "json_extract(value, '$.name') = ? OR "
                        "json_extract(value, '$.name') LIKE ? OR "
                        f"json_extract(value, '$.name') LIKE ?{extra_null}))"
                    )
                    params += [
                        arr_type,
                        f"Alt. {arr_type}%",
                        f"Bonus {arr_type}%",
                        arr_type,
                        f"Alt. {arr_type}%",
                        f"Bonus {arr_type}%",
                    ] + extra_null_params
                where += (
                    f" AND NOT EXISTS (SELECT 1 FROM json_each({alias}.arrangements) WHERE "
                    + " OR ".join(f"({c})" for c in clauses)
                    + ")"
                )
            else:
                placeholders = ",".join(["?"] * len(arr_lacks))
                where += (f" AND NOT EXISTS (SELECT 1 FROM json_each({alias}.arrangements) "
                          f"WHERE json_extract(value, '$.name') IN ({placeholders}))")
                params += arr_lacks
        stems_h = [s for s in (stems_has or []) if s in self._ALLOWED_STEM_IDS]
        if stems_h:
            placeholders = ",".join(["?"] * len(stems_h))
            where += (f" AND EXISTS (SELECT 1 FROM json_each({alias}.stem_ids) "
                      f"WHERE value IN ({placeholders}))")
            params += stems_h
        stems_l = [s for s in (stems_lacks or []) if s in self._ALLOWED_STEM_IDS]
        if stems_l:
            placeholders = ",".join(["?"] * len(stems_l))
            where += (f" AND NOT EXISTS (SELECT 1 FROM json_each({alias}.stem_ids) "
                      f"WHERE value IN ({placeholders}))")
            params += stems_l
        if has_lyrics in (0, 1):
            where += f" AND {alias}.has_lyrics = ?"
            params.append(has_lyrics)
        if tunings:
            # Keep the input cap conservative (32) so a hostile caller
            # can't blow out the parameter list. Real tuning sets in the
            # wild number in the low double digits.
            tn = [t for t in tunings if isinstance(t, str) and t][:32]
            if tn:
                placeholders = ",".join(["?"] * len(tn))
                # Match the same grouping key tuning_names() returns so a single
                # "Custom Tuning" pill selects exactly its offset set while named
                # tunings still match by name.
                where += (f" AND {_tuning_group_key_sql(alias)} "
                          f"COLLATE NOCASE IN ({placeholders})")
                params += tn
        return where, params

    # Under group=1, chart-intrinsic filters match if ANY member of the work
    # matches (§7.1 filter law). A pure predicate on the representative scan —
    # no GROUP BY, no row multiplication — so the keyset cursor stays valid.
    def _grouped_member_match(self, intrinsic_frag: str, intrinsic_params: list) -> tuple[str, list]:
        if not intrinsic_frag:
            return "", []
        return ((" AND EXISTS (SELECT 1 FROM songs m JOIN work_display mw ON mw.filename = m.filename "
                 "WHERE mw.effective_work_key = (SELECT w0.effective_work_key FROM work_display w0 "
                 "WHERE w0.filename = songs.filename)" + intrinsic_frag + ")"),
                list(intrinsic_params))

    # ── Multi-chart grouping engine (P5a) ────────────────────────────────────
    @staticmethod
    def _norm_token(s, fold_the=False):
        """Fold a name to a comparison token: strip diacritics + punctuation +
        whitespace, lowercase, optionally drop a leading 'the ' (artist names)."""
        import re
        import unicodedata
        raw = str(s or "")
        s = unicodedata.normalize("NFKD", raw)
        s = "".join(c for c in s if not unicodedata.combining(c)).lower()
        if fold_the:
            s = re.sub(r"^the\s+", "", s)
        folded = re.sub(r"[^a-z0-9]+", "", s)
        if folded:
            return folded
        # All-non-Latin titles (CJK/Cyrillic/Greek/Arabic) fold to "" above,
        # which would collapse every such song into one bogus work. Fall back to
        # the raw text lowercased with whitespace collapsed so distinct titles
        # keep distinct keys. Latin names always hit the `folded` branch, so
        # their behavior is unchanged.
        return re.sub(r"\s+", " ", raw.strip().lower())

    @classmethod
    def _work_key(cls, artist, title) -> str:
        """Identity of a musical WORK = normalize(artist)+'|'+normalize(title).
        Recording-MBID identity is a later enrichment upgrade (§3); this text key
        groups the common 'same song, several charts' case now."""
        return cls._norm_token(artist, fold_the=True) + "|" + cls._norm_token(title)

    def _alias_map_if_exists(self) -> dict:
        """{raw_artist_lower: canonical} from P4's artist_alias when that table is
        present, so work_key groups across artist aliases (ACDC/AC/DC) once P4 is
        merged; {} (→ raw artist) when it isn't. Forward-compatible, no hard P4 dep."""
        try:
            rows = self.conn.execute("SELECT raw_name, canonical_name FROM artist_alias").fetchall()
        except Exception:
            return {}
        return {r[0].lower(): r[1] for r in rows}

    @staticmethod
    def _pick_representative(members: list, prefs: dict) -> str:
        """The keeper chart of a group: the user's chart_group_pref when its file
        is present, else auto-pick = MOST-PLAYED (history-sticky, §7.1: real
        practice wins — a newer/'more complete' import must not silently take
        the pick from the chart your reps accrued on, and a one-off try of an
        alternate can't out-rank a practiced incumbent) → most-complete
        (arrangements) → newest → filename. An all-unplayed group therefore
        still picks by completeness. `members` = dicts {fn, wk, arr, plays, mtime}."""
        if members:
            pref = prefs.get(members[0]["wk"])
            if pref and any(m["fn"] == pref for m in members):
                return pref
        best = min(members, key=lambda m: (-m["plays"], -m["arr"], -m["mtime"], m["fn"]))
        return best["fn"]

    def _load_work_members(self):
        """Read songs + overrides → ({effective_work_key: [member dicts]}, prefs)."""
        amap = self._alias_map_if_exists()
        splits = dict(self.conn.execute(
            "SELECT filename, split_key FROM chart_group_split").fetchall())
        prefs = dict(self.conn.execute(
            "SELECT work_key, preferred_filename FROM chart_group_pref").fetchall())
        plays = dict(self.conn.execute(
            "SELECT filename, SUM(plays) FROM song_stats GROUP BY filename").fetchall())
        groups: dict = {}
        for fn, artist, title, arr_json, mtime in self.conn.execute(
                "SELECT filename, artist, title, arrangements, mtime FROM songs WHERE title != ''"):
            wk = self._work_key(amap.get((artist or "").lower(), artist), title)
            eff = splits.get(fn) or wk
            try:
                arr = len(json.loads(arr_json)) if arr_json else 0
            except Exception:
                arr = 0
            groups.setdefault(eff, []).append(
                {"fn": fn, "wk": wk, "arr": arr, "plays": int(plays.get(fn) or 0), "mtime": mtime or 0})
        return groups, prefs

    def rebuild_work_display(self) -> None:
        """Full re-materialization of work_display from songs + the override
        tables. O(n) — cheap enough to run lazily after any songs churn."""
        with self._lock:
            groups, prefs = self._load_work_members()
            out = []
            for eff, members in groups.items():
                rep = self._pick_representative(members, prefs)
                n = len(members)
                for m in members:
                    out.append((m["fn"], m["wk"], eff, 1 if m["fn"] == rep else 0, n))
            self.conn.execute("DELETE FROM work_display")
            if out:
                self.conn.executemany(
                    "INSERT INTO work_display (filename, work_key, effective_work_key, "
                    "is_group_representative, group_size) VALUES (?, ?, ?, ?, ?)", out)
            self.conn.commit()
            self._work_display_dirty = False

    def _ensure_work_display(self) -> None:
        """(Re)build the read-model when a change marked it dirty (or it's never
        been built). Called at the top of every grouped query."""
        if getattr(self, "_work_display_dirty", True):
            self.rebuild_work_display()

    def work_key_for(self, filename: str):
        """work_key of a song (from its current artist+title), or None if absent."""
        row = self.conn.execute(
            "SELECT artist, title FROM songs WHERE filename = ?", (filename,)).fetchone()
        if not row:
            return None
        amap = self._alias_map_if_exists()
        return self._work_key(amap.get((row[0] or "").lower(), row[0]), row[1])

    def set_chart_preferred(self, work_key: str, filename: str) -> None:
        """Pick the keeper chart of a work. Incremental: re-flips
        is_group_representative within the work's (non-split) group only —
        group_size is unchanged — so no full rebuild."""
        with self._lock:
            self.conn.execute(
                "INSERT INTO chart_group_pref (work_key, preferred_filename, updated_at) "
                "VALUES (?, ?, datetime('now')) "
                "ON CONFLICT(work_key) DO UPDATE SET "
                "preferred_filename = excluded.preferred_filename, updated_at = excluded.updated_at",
                (work_key, filename))
            if not self._work_display_dirty:
                members = [r[0] for r in self.conn.execute(
                    "SELECT filename FROM work_display WHERE effective_work_key = ?",
                    (work_key,)).fetchall()]
                if filename in members:
                    self.conn.execute(
                        "UPDATE work_display SET is_group_representative = "
                        "CASE WHEN filename = ? THEN 1 ELSE 0 END "
                        "WHERE effective_work_key = ?", (filename, work_key))
                else:
                    # pref target isn't a current member (orphan/split) — reconcile
                    # on the next lazy rebuild rather than leave it half-applied.
                    self._work_display_dirty = True
            self.conn.commit()

    def clear_chart_preferred(self, work_key: str) -> None:
        """Reset a work to auto-pick; lazy full rebuild."""
        with self._lock:
            self.conn.execute("DELETE FROM chart_group_pref WHERE work_key = ?", (work_key,))
            self._work_display_dirty = True
            self.conn.commit()

    def split_chart(self, filename: str) -> None:
        """'These aren't the same' — give a chart a unique split_key so it stands
        alone as a singleton work. Lazy full rebuild (the old group's membership +
        sizes shift)."""
        wk = self.work_key_for(filename) or filename
        with self._lock:
            self.conn.execute(
                "INSERT INTO chart_group_split (filename, split_key, updated_at) "
                "VALUES (?, ?, datetime('now')) "
                "ON CONFLICT(filename) DO UPDATE SET "
                "split_key = excluded.split_key, updated_at = excluded.updated_at",
                (filename, f"{wk}#split#{filename}"))
            self._work_display_dirty = True
            self.conn.commit()

    def unsplit_chart(self, filename: str) -> None:
        """Undo a split — the chart rejoins its work. Lazy full rebuild."""
        with self._lock:
            self.conn.execute("DELETE FROM chart_group_split WHERE filename = ?", (filename,))
            self._work_display_dirty = True
            self.conn.commit()

    def work_charts(self, work_key: str) -> dict:
        """Every chart in a work (P5b) — the Charts drawer's data. Members are the
        work's CURRENT (non-split) group: work_display rows whose effective_work_key
        matches. Each carries its effective title/artist, arrangements, tuning,
        format, best accuracy, and the representative/preferred flags so the drawer
        can label 'Preferred — your pick' vs 'Preferred (auto)'."""
        self._ensure_work_display()
        amap = self._alias_map_if_exists()
        pref_row = self.conn.execute(
            "SELECT preferred_filename FROM chart_group_pref WHERE work_key = ?", (work_key,)).fetchone()
        pref_fn = pref_row[0] if pref_row else None
        rows = self.conn.execute(
            "SELECT wd.filename, wd.is_group_representative, s.title, s.artist, s.album, s.year, "
            "s.arrangements, s.tuning_name, s.tuning, s.format, "
            "(SELECT MAX(best_accuracy) FROM song_stats st WHERE st.filename = wd.filename AND st.plays > 0) "
            "FROM work_display wd JOIN songs s ON s.filename = wd.filename "
            "WHERE wd.effective_work_key = ? "
            "ORDER BY wd.is_group_representative DESC, s.title COLLATE NOCASE, s.filename",
            (work_key,)).fetchall()
        charts = []
        for fn, is_rep, title, artist, album, year, arr_json, tuning_name, tuning, fmt, best in rows:
            try:
                arrangements = _ensure_smart_names(json.loads(arr_json) if arr_json else [])
            except Exception:
                arrangements = []
            charts.append({
                "filename": fn,
                "title": title or fn,
                "artist": amap.get((artist or "").lower(), artist) or "",
                "album": album or "", "year": year or "",
                "arrangements": arrangements,
                "tuning_name": tuning_name or "", "tuning": tuning or "",
                "format": fmt or "archive",
                "best_accuracy": best,
                "is_representative": bool(is_rep),
                "is_preferred": (fn == pref_fn),
            })
        return {
            "work_key": work_key,
            "count": len(charts),
            "preferred_filename": pref_fn,
            # Whether the keeper is your explicit pick or the auto-pick — drives the
            # drawer's "Preferred — your pick" vs "Preferred (auto)" label.
            "preferred_source": "user" if pref_fn else "auto",
            "charts": charts,
        }

    def chart_work(self, filename: str) -> dict:
        """The work a chart belongs to (P5d): its EFFECTIVE work_key (a split
        chart resolves to its own singleton key) + how many charts share it.
        Lets an opener resolve group membership for rows that didn't come from
        a grouped query — the tree view's rows ride the ungrouped artists
        endpoint, so they carry no chart_count/work_key annotation."""
        key = self._canonical_song_filename(filename)
        self._ensure_work_display()
        row = self.conn.execute(
            "SELECT effective_work_key, group_size FROM work_display WHERE filename = ?",
            (key,)).fetchone()
        if not row:
            return {"filename": key, "work_key": None, "chart_count": 0, "is_split": False}
        split = self.conn.execute(
            "SELECT 1 FROM chart_group_split WHERE filename = ?", (key,)).fetchone()
        return {"filename": key, "work_key": row[0], "chart_count": row[1],
                "is_split": bool(split)}

    # Predicate that narrows a query to one representative chart per work — the
    # keyset-safe grouping filter (see query_page / query_stats).
    _GROUP_REP_PREDICATE = " AND filename IN (SELECT filename FROM work_display WHERE is_group_representative = 1)"

    def query_page(self, q: str = "", page: int = 0, size: int = 24,
                   sort: str = "artist", direction: str = "asc",
                   favorites_only: bool = False,
                   format_filter: str = "",
                   artist_filter: str = "",
                   album_filter: str = "",
                   arrangements_has: list[str] | None = None,
                   arrangements_lacks: list[str] | None = None,
                   stems_has: list[str] | None = None,
                   stems_lacks: list[str] | None = None,
                   has_lyrics: int | None = None,
                   tunings: list[str] | None = None,
                   mastery: list[str] | None = None,
                   tags_has: list[str] | None = None,
                   user_difficulty_in: list[str] | None = None,
                   match_states: list[str] | None = None,
                   genre: list[str] | None = None,
                   after: str | None = None,
                   group: bool = False,
                   naming_mode: str = "legacy") -> tuple[list[dict], int]:
        """Server-side paginated search. Returns (songs, total_count).

        `after` is an opaque keyset cursor (the last row of the previous page).
        When supplied and the sort can keyset, the page is fetched with a
        WHERE-seek instead of OFFSET — O(page), independent of depth. Unknown
        sorts / bad cursors fall back to OFFSET, so it's always safe.

        `group` collapses a work's charts to one card (P5a): it adds a single
        `WHERE is_group_representative = 1` predicate over the materialized
        work_display, so the total counts WORKS not charts and the keyset seek /
        sort / A–Z all stay correct over the representative subset. Each grouped
        row carries `chart_count` (the ⚑ N).

        Filter law under grouping (P5e, §7.1): work-identity (artist/album/q)
        + practice-state (favorites/mastery/tags/difficulty) predicates stay on
        the representative row (identity ≈ the work; practice-state anchors on
        the preferred chart), while CHART-INTRINSIC predicates (format/
        arrangements/stems/lyrics/tuning) match if ANY member of the work does
        — and when the representative itself doesn't match, the row carries a
        `display_chart` override so the card can show/play the matching one."""
        where, params = self._build_where(
            q=q, favorites_only=favorites_only, format_filter=format_filter,
            artist_filter=artist_filter, album_filter=album_filter,
            arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
            stems_has=stems_has, stems_lacks=stems_lacks,
            has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
            tags_has=tags_has, user_difficulty_in=user_difficulty_in,
            match_states=match_states, genre=genre,
            naming_mode=naming_mode, include_intrinsic=not group,
        )
        ifrag, iparams = "", []
        if group:
            self._ensure_work_display()
            ifrag, iparams = self._build_intrinsic_where(
                "m", format_filter=format_filter,
                arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
                stems_has=stems_has, stems_lacks=stems_lacks,
                has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
            mfrag, mparams = self._grouped_member_match(ifrag, iparams)
            where += mfrag
            params += mparams
            where += self._GROUP_REP_PREDICATE

        sort_map = {
            # Artist sorts order WITHIN an artist by title (the tree view's
            # artist -> album -> title feel) instead of raw filename — the
            # "list is organised, cards look random" report. Direction is
            # baked per entry (the legacy `dir=desc` append would otherwise
            # land on the title term); title stays ascending under Z->A.
            "artist": "artist COLLATE NOCASE ASC, title COLLATE NOCASE ASC",
            "artist-desc": "artist COLLATE NOCASE DESC, title COLLATE NOCASE ASC",
            "title": "title COLLATE NOCASE", "title-desc": "title COLLATE NOCASE DESC",
            "recent": "mtime DESC",
            # Tuning sort uses musical distance from E Standard
            # (feedBack#22 — was alphabetical). `tuning_sort_key` is
            # the sum of per-string offsets, so |sort_key| is the
            # magnitude of the down/up-tune. ABS ascending puts E
            # Standard (0) first, then ±2 (Drop D, F Standard), then
            # ±6 (Eb Standard, F# Standard), and so on. Within a
            # magnitude tier we break ties by signed key ASC so the
            # negative (down-tuned) variant comes before the positive
            # (up-tuned) one — Eb Standard before F Standard, matching
            # how the app groups its tuning list. Final tiebreak by
            # name keeps the order fully deterministic.
            #
            # Leading term pushes pre-migration / unscanned rows to
            # the bottom — without it ABS(0) collides with E
            # Standard's 0 and unindexed rows would sort first.
            # COALESCE on every column the clause references guards
            # against NULL values — SQLite's literal-constant ADD
            # COLUMN does backfill on most versions, but raw SQL
            # inserts that bypass `put()`, edge-case migration paths,
            # or future code that writes None could still leave NULLs
            # behind, and a NULL `tuning_name` in `(tuning_name = '')`
            # evaluates to NULL itself (which sorts ahead of 0 in
            # ASC), defeating the push-to-bottom intent.
            "tuning": (
                "(COALESCE(tuning_name, '') = '') ASC, "
                "ABS(COALESCE(tuning_sort_key, 0)), "
                "COALESCE(tuning_sort_key, 0) ASC, "
                "COALESCE(tuning_name, '') COLLATE NOCASE"
            ),
            # Year sort (feedBack#128). Empty-year rows pushed to the
            # bottom for both directions; otherwise CAST so '2010' >
            # '2005' rather than alphabetic.
            "year": "(year = '') ASC, CAST(year AS INTEGER) ASC",
            "year-desc": "(year = '') ASC, CAST(year AS INTEGER) DESC",
            # Album track order: authored track number (disc, then track); songs
            # with no number fall to the bottom, ordered by title. Used by the
            # album detail view. Alpha-by-title is the fallback when unauthored.
            "track": "(track_number IS NULL) ASC, COALESCE(disc, 1), track_number, title COLLATE NOCASE",
            # Mastery = best accuracy across a song's arrangements, from the
            # separate song_stats table (so via a correlated subquery — this sort
            # drops to OFFSET paging, like tuning/year). Unscored ("not started")
            # songs push to the BOTTOM in both directions (the IS NULL term);
            # ascending is "needs practice first" (weakest measured first),
            # descending is "most mastered first".
            "mastery": (
                "((SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) IS NULL) ASC, "
                "(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) ASC"
            ),
            "mastery-desc": (
                "((SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) IS NULL) ASC, "
                "(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) DESC"
            ),
            # Personal difficulty rating (song_user_meta.user_difficulty, 1..5 —
            # manually set or seeded by the difficulty_tagger plugin), via a
            # correlated subquery like mastery above (drops to OFFSET paging).
            # Unrated songs push to the bottom in both directions.
            "difficulty": (
                "((SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) IS NULL) ASC, "
                "(SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) ASC"
            ),
            "difficulty-desc": (
                "((SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) IS NULL) ASC, "
                "(SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) DESC"
            ),
        }
        if group and sort in ("mastery", "mastery-desc"):
            # Sort law (§7.1): mastery aggregates MAX across the WHOLE group —
            # a song surfaces on any chart you've touched, even when the
            # preferred chart is unplayed. Mastery never keysets (OFFSET
            # paging), so the aggregate can't disturb a cursor. The recency
            # ("Recently Added") aggregate is deliberately NOT applied: mtime
            # IS a keyset sort, so its aggregate would need materializing into
            # work_display to stay cursor-safe — deferred until wanted (the
            # auto-pick's `newest` factor already surfaces new charts of
            # unplayed works; played works stay put by the sticky rule).
            _gm = ("(SELECT MAX(st.best_accuracy) FROM song_stats st "
                   "JOIN work_display sw ON sw.filename = st.filename "
                   "WHERE sw.effective_work_key = (SELECT w1.effective_work_key "
                   "FROM work_display w1 WHERE w1.filename = songs.filename))")
            sort_map["mastery"] = f"({_gm} IS NULL) ASC, {_gm} ASC"
            sort_map["mastery-desc"] = f"({_gm} IS NULL) ASC, {_gm} DESC"
        # Fold the legacy `dir=desc` toggle into the canonical sort key BEFORE
        # the lookup, so the ORDER BY is built from the effective sort — mirrors
        # what `_effective_keyset_sort` does on the cursor side. Needed because
        # the artist clause now bakes in `ASC` (for the title secondary), so the
        # ` DESC` append below is suppressed and would otherwise silently ignore
        # `sort=artist&dir=desc` (return A→Z). Only artist/title fold (they have
        # `-desc` twins); tuning/year/mastery keep their own dir handling.
        eff = _effective_keyset_sort(sort, direction)
        order = sort_map.get(eff, "artist COLLATE NOCASE")
        # Legacy `dir=desc` toggle: only safe to append on simple sort
        # clauses that don't already encode a direction. Compound /
        # multi-term entries above (artist, tuning, year, year-desc) bake their
        # ASC/DESC into the clause, so a global ` DESC` append would
        # produce invalid SQL like `CAST(year AS INTEGER) ASC DESC`.
        # Skip the append in that case — clients flipping direction on
        # those sorts use the explicit `-desc` sort key instead. (For
        # artist/title the fold above already picked the `-desc` clause.)
        if direction == "desc" and " ASC" not in order and " DESC" not in order:
            order += " DESC"
        # Unique, deterministic tiebreak → a TOTAL order. Without it, rows with
        # an equal sort key can reshuffle between OFFSET pages (skip/dupe); it's
        # also what makes keyset seeking correct.
        order += ", filename"

        # Grouped reads filter through the materialized work_display (the
        # `is_group_representative=1` predicate). rebuild_work_display does
        # DELETE→INSERT→commit under self._lock, so a lock-free reader on
        # another thread (shared conn, check_same_thread=False) could land its
        # SELECT in the mid-rebuild window and see 0 rows. Hold self._lock
        # across the representative COUNT+SELECT so it can't overlap a rebuild.
        # _ensure_work_display already rebuilt above under its own lock (and
        # self._lock is NOT reentrant), so we must NOT nest it here. Ungrouped
        # reads stay lock-free (WAL) via nullcontext.
        read_guard = self._lock if group else contextlib.nullcontext()
        with read_guard:
            total = self.conn.execute(f"SELECT COUNT(*) FROM songs {where}", params).fetchone()[0]

            cols = ("SELECT filename, title, artist, album, year, duration, tuning, "
                    "arrangements, has_lyrics, mtime, format, stem_count, stem_ids, "
                    "tuning_name, tuning_offsets FROM songs ")
            cursor = _decode_cursor(after) if after else None
            eff_sort = _effective_keyset_sort(sort, direction)
            if cursor and eff_sort in _KEYSET_SORTS:
                # Keyset seek: rows strictly after the cursor in the total order
                # `<col> <dir>, filename ASC` (NULL-aware, so == OFFSET exactly).
                col, collate, primary_dir = _KEYSET_SORTS[eff_sort]
                seek, seek_params = _keyset_seek(col, collate, primary_dir, cursor[0], cursor[1])
                seek_where = where + (" AND " if where else " WHERE ") + seek
                rows = self.conn.execute(
                    f"{cols}{seek_where} ORDER BY {order} LIMIT ?",
                    params + seek_params + [size],
                ).fetchall()
            else:
                rows = self.conn.execute(
                    f"{cols}{where} ORDER BY {order} LIMIT ? OFFSET ?",
                    params + [size, page * size],
                ).fetchall()

        estd = self._estd_set()
        favs = self.favorite_set()
        songs = []
        for r in rows:
            songs.append({
                "filename": r[0], "title": r[1], "artist": r[2], "album": r[3],
                "year": r[4], "duration": r[5], "tuning": r[6],
                "arrangements": _ensure_smart_names(json.loads(r[7]) if r[7] else []),
                "has_lyrics": bool(r[8]), "mtime": r[9],
                "format": r[10] or "archive",
                "stem_count": int(r[11] or 0),
                "stem_ids": json.loads(r[12]) if r[12] else [],
                "tuning_name": r[13] or "",
                "tuning_offsets": r[14] or "",
                "has_estd": r[0] in estd, "favorite": r[0] in favs,
            })
        # Personal layer (difficulty + tags) rides along like `favorite`, so a
        # card can badge it without a second request. Notes stay OUT of the list
        # payload (they can be long) — fetch per-song via /user-meta. Batched to
        # avoid an N+1 over the page.
        fns = [s["filename"] for s in songs]
        udm = self.user_meta_map(fns)
        tgm = self.tags_map(fns)
        # Enrichment "no match" (failed) set for the page, so a card can show a
        # persistent "no match" badge — the Refresh-Metadata batch's transient
        # per-tile state only paints while a pass runs. Cheap set membership like
        # favs/estd, so the misses stay visible at rest.
        um = self._unmatched_set(fns)
        # Per-song display OVERRIDES (Fix-metadata popup, slice 3). "Grid shows
        # only overrides": the effective cell is the user's override else the
        # pack value — a matched MusicBrainz canon NEVER silently re-titles a
        # card (canon lives in the Details drawer + art). Overlaid in Python
        # over the visible window, keyset-safe exactly like the P4 alias re-label
        # below: the seek still runs on the raw column (the one overridable
        # keyset column, title, stashes its raw value for the cursor — see
        # _sort_title / next_library_cursor).
        omap = self.overrides_map(fns)
        # Canonical artist at display (P4): re-label the card's artist through the
        # alias override so "ACDC" reads as "AC/DC". Display-only — the row's sort
        # position (raw artist) is untouched, so a card can show a canonical name
        # that differs from its A–Z bucket for cross-letter aliases; the full
        # sort/rail reindex under aliases is the P5a materialization pass.
        amap = self.alias_map()
        for s in songs:
            s["user_difficulty"] = udm.get(s["filename"])
            s["tags"] = tgm.get(s["filename"], [])
            s["unmatched"] = s["filename"] in um
            if amap:
                s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist"))
            # English-base romaji fallback: a blank-artist CDLC pack shows nothing
            # useful (artist blank; title = the raw filename). Surface the author's
            # romaji from the "Artist_Title_v1_p" filename so the card reads
            # "Junko Yagami — BAY CITY", never blank or native script. Display-only;
            # a user override (below) still wins. Keyset-safe: stash the raw title
            # for the cursor before replacing it.
            if not (s.get("artist") or "").strip():
                r_artist, r_title = self._romaji_display(s["filename"], s.get("artist"), s.get("title"))
                if r_title != s.get("title") and "_sort_title" not in s:
                    s["_sort_title"] = s["title"]
                s["artist"], s["title"] = r_artist, r_title
            # Override wins over the pack AND the alias re-label — it's the user's
            # explicit per-song choice. Only a non-empty override VALUE replaces a
            # cell; a lock-only row (value None) leaves the displayed value alone.
            ov = omap.get(s["filename"])
            if ov:
                for field in ("title", "artist", "album", "year"):
                    cell = ov.get(field)
                    val = cell.get("value") if cell else None
                    if val:
                        if field == "title" and "_sort_title" not in s:
                            s["_sort_title"] = s["title"]   # raw title, for the keyset cursor
                        s[field] = val
        # Grouped rows carry the ⚑ N (chart_count) + the work_key from the
        # materialized read-model, so the card can render the "N charts" chip and
        # address the Charts drawer (GET /api/work/{work_key}/charts) without a
        # second request — plus `is_split` (P5e) so the ⋮ menu can offer the
        # "Rejoin other versions" undo on a split-out chart.
        if group and fns:
            ph = ",".join("?" * len(fns))
            wd = {r[0]: (r[1], r[2], r[3]) for r in self.conn.execute(
                "SELECT filename, group_size, work_key, effective_work_key "
                f"FROM work_display WHERE filename IN ({ph})", fns).fetchall()}
            splits = {r[0] for r in self.conn.execute(
                f"SELECT filename FROM chart_group_split WHERE filename IN ({ph})", fns).fetchall()}
            eff_by_fn = {}
            for s in songs:
                gs, wk, eff = wd.get(s["filename"], (1, None, None))
                s["chart_count"] = gs
                s["work_key"] = wk
                s["is_split"] = s["filename"] in splits
                if eff:
                    eff_by_fn[s["filename"]] = eff
            if ifrag:
                self._attach_display_charts(songs, eff_by_fn, ifrag, iparams)
        return songs, total

    def _attach_display_charts(self, songs: list[dict], eff_by_fn: dict,
                               intrinsic_frag: str, intrinsic_params: list) -> None:
        """§7.1: when chart-intrinsic filters admit a work through a member the
        REPRESENTATIVE doesn't itself satisfy, the card 'switches its displayed
        chart to a matching one'. The row (sort keys, cursor identity, the
        mastery/favorite anchor) stays the representative's — only the
        display/play facts ride along under `display_chart`, so keyset paging
        and the practice-state anchor are untouched. `intrinsic_frag`/`params`
        are the member-aliased ('m') predicates already built by the caller."""
        keys = sorted(set(eff_by_fn.values()))
        if not keys:
            return
        ph = ",".join("?" * len(keys))
        rows = self.conn.execute(
            "SELECT mw.effective_work_key, m.filename, m.title, m.duration, m.tuning, "
            "m.arrangements, m.has_lyrics, m.mtime, m.format, m.stem_count, m.stem_ids, "
            "m.tuning_name, m.tuning_offsets "
            "FROM songs m JOIN work_display mw ON mw.filename = m.filename "
            f"WHERE mw.effective_work_key IN ({ph}){intrinsic_frag} "
            "ORDER BY mw.is_group_representative DESC, m.mtime DESC, m.filename",
            keys + list(intrinsic_params)).fetchall()
        best: dict = {}
        for r in rows:
            best.setdefault(r[0], r)   # rep-first, then newest — one match per work
        for s in songs:
            m = best.get(eff_by_fn.get(s["filename"]))
            if not m or m[1] == s["filename"]:
                continue   # the representative itself matches (or nothing does)
            s["display_chart"] = {
                "filename": m[1], "title": m[2] or m[1], "duration": m[3],
                "tuning": m[4],
                "arrangements": _ensure_smart_names(json.loads(m[5]) if m[5] else []),
                "has_lyrics": bool(m[6]), "mtime": m[7],
                "format": m[8] or "archive",
                "stem_count": int(m[9] or 0),
                "stem_ids": json.loads(m[10]) if m[10] else [],
                "tuning_name": m[11] or "", "tuning_offsets": m[12] or "",
            }

    def query_artists(self, letter: str = "", q: str = "",
                      favorites_only: bool = False,
                      page: int = 0, size: int = 50,
                      format_filter: str = "",
                      artist_filter: str = "",
                      album_filter: str = "",
                      arrangements_has: list[str] | None = None,
                      arrangements_lacks: list[str] | None = None,
                      stems_has: list[str] | None = None,
                      stems_lacks: list[str] | None = None,
                      has_lyrics: int | None = None,
                      tunings: list[str] | None = None,
                      naming_mode: str = "legacy") -> tuple[list[dict], int]:
        """Get artists grouped by letter with their albums and songs. Returns (artists, total_artists)."""
        where, params = self._build_where(
            q=q, favorites_only=favorites_only, format_filter=format_filter,
            artist_filter=artist_filter, album_filter=album_filter,
            arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
            stems_has=stems_has, stems_lacks=stems_lacks,
            has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
        )
        # Canonicalize artists at display when aliases exist (P4): dedupe / group /
        # letter / order on the EFFECTIVE artist so "ACDC" + "AC/DC" list as one
        # entry. With no aliases, `art_expr` stays the plain (indexed) `artist`
        # column, so the common case pays zero subquery cost.
        has_aliases = self.conn.execute("SELECT 1 FROM artist_alias LIMIT 1").fetchone() is not None
        art_expr = self._EFFECTIVE_ARTIST_SQL if has_aliases else "artist"

        if letter == "#":
            where += f" AND ({art_expr}) NOT GLOB '[A-Za-z]*'"
        elif letter:
            where += f" AND UPPER(SUBSTR(({art_expr}), 1, 1)) = ?"
            params.append(letter.upper())

        # Get paginated distinct (effective) artists
        total_artists = self.conn.execute(
            f"SELECT COUNT(DISTINCT ({art_expr}) COLLATE NOCASE) FROM songs {where}", params
        ).fetchone()[0]

        artist_rows = self.conn.execute(
            f"SELECT DISTINCT ({art_expr}) COLLATE NOCASE as a FROM songs {where} ORDER BY a LIMIT ? OFFSET ?",
            params + [size, page * size]
        ).fetchall()
        artist_names = [r[0] for r in artist_rows]

        if not artist_names:
            return [], total_artists

        # Fetch songs for these (effective) artists only
        placeholders = ",".join(["?"] * len(artist_names))
        song_where = f"{where} AND ({art_expr}) COLLATE NOCASE IN ({placeholders})"
        song_params = params + artist_names

        rows = self.conn.execute(
            f"SELECT filename, title, ({art_expr}) as artist, album, year, duration, tuning, arrangements, has_lyrics, "
            f"format, stem_count, stem_ids, tuning_name "
            f"FROM songs {song_where} ORDER BY ({art_expr}) COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE",
            song_params
        ).fetchall()

        # Group into artist -> album -> songs
        from collections import OrderedDict
        estd = self._estd_set()
        favs = self.favorite_set()
        # Personal difficulty rides along here too (feedBack#810 follow-up),
        # same batched pattern as query_page — without this the tree view's
        # difficulty badge silently never renders (song.user_difficulty was
        # always undefined for every row).
        udm = self.user_meta_map([r[0] for r in rows])
        artists = OrderedDict()
        for r in rows:
            artist = r[2] or "Unknown Artist"
            album = r[3] or "Unknown Album"
            akey = artist.lower()
            if akey not in artists:
                artists[akey] = {"name": artist, "albums": OrderedDict()}
            bkey = album.lower()
            if bkey not in artists[akey]["albums"]:
                artists[akey]["albums"][bkey] = {"name": album, "songs": []}
            artists[akey]["albums"][bkey]["songs"].append({
                "filename": r[0], "title": r[1], "artist": r[2], "album": r[3],
                "year": r[4], "duration": r[5], "tuning": r[6],
                "arrangements": _ensure_smart_names(json.loads(r[7]) if r[7] else []),
                "has_lyrics": bool(r[8]),
                "format": r[9] or "archive",
                "stem_count": int(r[10] or 0),
                "stem_ids": json.loads(r[11]) if r[11] else [],
                "tuning_name": r[12] or "",
                "has_estd": r[0] in estd,
                "favorite": r[0] in favs,
                "user_difficulty": udm.get(r[0]),
            })

        # Pick most common name variant per artist/album
        result = []
        for akey, aval in artists.items():
            albums = []
            for bkey, bval in aval["albums"].items():
                albums.append({"name": bval["name"], "songs": bval["songs"]})
            result.append({"name": aval["name"], "album_count": len(albums),
                           "song_count": sum(len(a["songs"]) for a in albums), "albums": albums})
        return result, total_artists

    def query_albums(self, q="", favorites_only=False, format_filter="",
                     artist_filter="", album_filter="",
                     arrangements_has=None, arrangements_lacks=None,
                     stems_has=None, stems_lacks=None,
                     has_lyrics=None, tunings=None, mastery=None,
                     match_states=None, genre=None,
                     naming_mode="legacy", page=0, size=120):
        """Distinct (artist, album) groups with a track count + a representative
        cover song, for the album-condensed browse (paged by album). Rows with no
        album name are excluded -- they can't form an album card. Same filters as
        query_page."""
        where, params = self._build_where(
            q=q, favorites_only=favorites_only, format_filter=format_filter,
            artist_filter=artist_filter, album_filter=album_filter,
            arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
            stems_has=stems_has, stems_lacks=stems_lacks,
            has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
            match_states=match_states, genre=genre,
            naming_mode=naming_mode,
        )
        awhere = where + " AND album IS NOT NULL AND album != ''"
        total = self.conn.execute(
            f"SELECT COUNT(*) FROM (SELECT 1 FROM songs {awhere} "
            f"GROUP BY artist COLLATE NOCASE, album COLLATE NOCASE)", params
        ).fetchone()[0]
        rows = self.conn.execute(
            f"SELECT artist, album, COUNT(*) AS n, MIN(filename) AS cover "
            f"FROM songs {awhere} "
            f"GROUP BY artist COLLATE NOCASE, album COLLATE NOCASE "
            f"ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE LIMIT ? OFFSET ?",
            params + [size, page * size]
        ).fetchall()
        return ([{"artist": r[0] or "Unknown Artist", "album": r[1] or "Unknown Album",
                  "count": int(r[2] or 0), "cover": r[3]} for r in rows], total)

    def query_stats(self, favorites_only: bool = False,
                    q: str = "", format_filter: str = "",
                    artist_filter: str = "",
                    album_filter: str = "",
                    arrangements_has: list[str] | None = None,
                    arrangements_lacks: list[str] | None = None,
                    stems_has: list[str] | None = None,
                    stems_lacks: list[str] | None = None,
                    has_lyrics: int | None = None,
                    tunings: list[str] | None = None,
                    match_states: list[str] | None = None,
                    sort: str = "artist",
                    want_sort_letters: bool = False,
                    group: bool = False,
                    naming_mode: str = "legacy") -> dict:
        """Aggregate stats for the letter bar. Accepts the same filter
        params as query_page so the letter counts stay synchronized
        with the grid when filters are active.

        `group` (P5a) restricts every count to one representative chart per work
        (the same predicate query_page uses), so `total_songs` and the jump-rail
        `sort_letters` count WORKS not charts and stay in lockstep with the
        grouped grid.

        `sort` selects the column the v3 jump rail's `sort_letters`
        breakdown keys on (artist for artist sorts, title for title
        sorts) so the rail's present-letters match the grid's actual
        order; other sorts fall back to artist (the rail is hidden for
        them client-side anyway). The legacy `letters` field is always
        the artist breakdown, unchanged, for the dashboard + classic tree.

        `sort_letters` is computed (and the key included) ONLY when
        `want_sort_letters` is set — the jump rail opts in, while the
        dashboard / v2 tree read only `letters` and skip the extra
        per-letter aggregate scan."""
        where, params = self._build_where(
            q=q, favorites_only=favorites_only, format_filter=format_filter,
            artist_filter=artist_filter, album_filter=album_filter,
            arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
            stems_has=stems_has, stems_lacks=stems_lacks,
            has_lyrics=has_lyrics, tunings=tunings, match_states=match_states,
            naming_mode=naming_mode,
            include_intrinsic=not group,
        )
        if group:
            # Same filter law as query_page (§7.1): chart-intrinsic predicates
            # match-if-ANY-member, applied identically here so the letter-bar
            # counts stay in lockstep with the grouped grid.
            self._ensure_work_display()
            ifrag, iparams = self._build_intrinsic_where(
                "m", format_filter=format_filter,
                arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
                stems_has=stems_has, stems_lacks=stems_lacks,
                has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
            mfrag, mparams = self._grouped_member_match(ifrag, iparams)
            where += mfrag
            params += mparams
            where += self._GROUP_REP_PREDICATE
        # Grouped stat counts filter through work_display (same
        # is_group_representative=1 predicate as query_page); hold self._lock
        # across these representative SELECTs so they can't observe a
        # mid-rebuild empty table (see query_page for the full rationale).
        # _ensure_work_display already rebuilt above under its own lock, so we
        # do NOT nest it here (self._lock is non-reentrant). Ungrouped reads
        # stay lock-free (WAL) via nullcontext.
        read_guard = self._lock if group else contextlib.nullcontext()
        with read_guard:
            total = self.conn.execute(f"SELECT COUNT(*) FROM songs {where}", params).fetchone()[0]
            # NOCASE collation here mirrors `query_artists` and the per-
            # letter `COUNT(DISTINCT artist COLLATE NOCASE)` below — without
            # it, an artist stored under two different casings would inflate
            # `total_artists` against the letter-bar breakdown the UI
            # renders next to it.
            artist_count = self.conn.execute(
                f"SELECT COUNT(DISTINCT artist COLLATE NOCASE) FROM songs {where}", params
            ).fetchone()[0]
            rows = self.conn.execute(
                f"SELECT UPPER(SUBSTR(artist, 1, 1)) as letter, COUNT(DISTINCT artist COLLATE NOCASE) "
                f"FROM songs {where} GROUP BY letter", params
            ).fetchall()
        letters = {}
        for letter, count in rows:
            count = int(count or 0)
            if count <= 0:
                continue
            key = str(letter or "")
            if key.isascii() and key.isalpha():
                letters[key] = letters.get(key, 0) + count
            else:
                letters["#"] = letters.get("#", 0) + count
        result = {"total_songs": total, "total_artists": artist_count, "letters": letters}
        # Active-sort letter buckets for the v3 jump rail. Counts SONGS (the
        # grid's unit, unlike `letters` which counts distinct artists) per
        # first-letter bucket of the column the active sort keys on, so a tap
        # on a present letter always finds a card. Non-A–Z first chars bucket
        # under '#'. Only artist/title sorts are alphabetical; anything else
        # keys on artist here but the client hides the rail for it. Computed
        # only when the caller opts in, so non-rail callers skip the scan.
        if want_sort_letters:
            sort_col = "title" if sort in ("title", "title-desc") else "artist"
            # Same representative-SELECT lock guard as the counts above.
            with read_guard:
                sort_rows = self.conn.execute(
                    f"SELECT UPPER(SUBSTR(COALESCE({sort_col}, ''), 1, 1)) AS letter, COUNT(*) "
                    f"FROM songs {where} GROUP BY letter", params
                ).fetchall()
            sort_letters: dict[str, int] = {}
            for letter, count in sort_rows:
                count = int(count or 0)
                if count <= 0:
                    continue
                key = str(letter or "")
                bucket = key if (key.isascii() and key.isalpha()) else "#"
                sort_letters[bucket] = sort_letters.get(bucket, 0) + count
            result["sort_letters"] = sort_letters
        return result
