#!/usr/bin/env python3
"""
Library metadata cache for neo-pod-desktop.

Stored as SQLite-format database in ``~/.local/share/neo-pod-desktop/library_cache.db``.

Each row is keyed by its **file path** and also carries a stable **UUID** so
play-count / rating / last-played data survives file renames, moves, and
re-encodings.  The database is thread-safe (WAL + busy_timeout) and
automatically rotated to a backup file on every write.

Migration from the old JSON file (``~/.cache/neo-pod-desktop/library_cache.json``)
happens automatically on the first access.
"""

import hashlib
import json
import logging
import os
import sqlite3
import threading
import time
import uuid
from typing import Any, Iterator

from xdg_base import xdg_data_path

logger = logging.getLogger(__name__)

_DB_FILE = "library_cache.db"
_BACKUP_FILE = "library_cache.db.backup"
_OLD_JSON_FILE = "library_cache.json"

_SCHEMA_VERSION = 2

_SCHEMA = """
CREATE TABLE IF NOT EXISTS library_cache (
    path           TEXT PRIMARY KEY,
    uuid           TEXT UNIQUE NOT NULL,
    title          TEXT DEFAULT '',
    artist         TEXT DEFAULT '',
    album          TEXT DEFAULT '',
    album_artist   TEXT DEFAULT '',
    genre          TEXT DEFAULT '',
    duration_s     REAL DEFAULT 0,
    size           INTEGER DEFAULT 0,
    track_num      INTEGER DEFAULT 0,
    source_type    TEXT DEFAULT '',
    bitrate        INTEGER DEFAULT 0,
    sample_rate    INTEGER DEFAULT 44100,
    cover_path     TEXT DEFAULT '',
    thumbnail_url  TEXT DEFAULT '',
    play_count     INTEGER DEFAULT 0,
    rating         INTEGER DEFAULT 0,
    last_played    INTEGER DEFAULT 0,
    skip_count     INTEGER DEFAULT 0,
    mtime          REAL NOT NULL DEFAULT 0,
    content_hash   TEXT NOT NULL DEFAULT '',
    updated_at     INTEGER DEFAULT (cast(strftime('%s','now') as int))
);

CREATE INDEX IF NOT EXISTS idx_lc_uuid ON library_cache(uuid);
CREATE INDEX IF NOT EXISTS idx_lc_hash ON library_cache(content_hash);
CREATE INDEX IF NOT EXISTS idx_lc_artist ON library_cache(artist);
CREATE INDEX IF NOT EXISTS idx_lc_album ON library_cache(album);

CREATE TABLE IF NOT EXISTS playcounts_log (
    firewire_id      TEXT PRIMARY KEY,
    last_synced_delta TEXT NOT NULL DEFAULT '{}'
);

"""


# ---------------------------------------------------------------------------
# Fast content fingerprint
# ---------------------------------------------------------------------------

def _content_hash(filepath: str, max_bytes: int = 65536) -> str:
    """SHA1 of first *max_bytes* of audio data."""
    h = hashlib.sha1()
    try:
        with open(filepath, "rb") as f:
            h.update(f.read(max_bytes))
    except OSError:
        return ""
    return h.hexdigest()


# ---------------------------------------------------------------------------
# Singleton helpers (kept for backward compatibility)
# ---------------------------------------------------------------------------

_library_cache: "LibraryCache | None" = None
_migrated_flag = False


def get_library_cache() -> "LibraryCache":
    global _library_cache
    if _library_cache is None:
        _library_cache = LibraryCache()
        _library_cache._migrate_old()
    return _library_cache


def save_library_cache() -> None:
    global _library_cache
    if _library_cache is not None:
        _library_cache.save()


def invalidate_cache_for_path(file_path: str) -> None:
    cache = get_library_cache()
    cache.remove(file_path)


# ---------------------------------------------------------------------------
# SQLite-backed cache
# ---------------------------------------------------------------------------

class LibraryCache:
    """Persistent cache backed by a single SQLite database.

    Public API (unchanged from the JSON version):
        get(path)       → dict | None
        put(path, data) → None
        remove(path)    → None
        clear()         → None
        save()          → None
        __len__()       → int

    New public API:
        iter_entries()              → Iterator[(path, dict)]
        get_by_content_hash(chash)  → dict | None
        get_by_uuid(uid)            → dict | None
        update_play_count(path)     → int (increment + return)
    """

    def __init__(self, db_path: str | None = None):
        if db_path is None:
            db_path = os.path.join(xdg_data_path(), _DB_FILE)
        self._path = db_path
        self._lock = threading.RLock()

        self.conn = sqlite3.connect(
            db_path, check_same_thread=False,
            detect_types=sqlite3.PARSE_DECLTYPES,
        )
        self.conn.row_factory = sqlite3.Row
        self.conn.execute("PRAGMA journal_mode=WAL")
        self.conn.execute("PRAGMA busy_timeout=5000")
        self.conn.execute("PRAGMA synchronous=NORMAL")
        self.conn.execute("PRAGMA foreign_keys=OFF")
        self._init_db()

    def _init_db(self) -> None:
        self._migrate_schema()
        self.conn.executescript(_SCHEMA)
        self.conn.execute(f"PRAGMA user_version = {_SCHEMA_VERSION}")
        self.conn.commit()

    def _migrate_schema(self) -> None:
        cur_ver = self.conn.execute("PRAGMA user_version").fetchone()[0]
        if cur_ver >= 2:
            return
        if cur_ver == 1:
            self.conn.execute("DROP TABLE IF EXISTS playcounts_log")

    # ── public read API ────────────────────────────────────────────────

    def get(self, file_path: str) -> dict[str, Any] | None:
        """Return cached data for *file_path*, or None if missing or stale."""
        with self._lock:
            row = self.conn.execute(
                "SELECT * FROM library_cache WHERE path=?",
                (file_path,),
            ).fetchone()
        if row is None:
            return None
        if not os.path.isfile(file_path):
            return None
        actual_mtime = os.path.getmtime(file_path)
        if actual_mtime != row["mtime"]:
            return None
        return self._row_to_dict(row)

    def get_by_content_hash(self, content_hash: str) -> dict[str, Any] | None:
        """Look up a track by its content fingerprint."""
        with self._lock:
            row = self.conn.execute(
                "SELECT * FROM library_cache WHERE content_hash=?",
                (content_hash,),
            ).fetchone()
        if row is None:
            return None
        return self._row_to_dict(row)

    def get_by_uuid(self, track_uuid: str) -> dict[str, Any] | None:
        """Look up a track by its stable UUID."""
        with self._lock:
            row = self.conn.execute(
                "SELECT * FROM library_cache WHERE uuid=?",
                (track_uuid,),
            ).fetchone()
        if row is None:
            return None
        return self._row_to_dict(row)

    def iter_entries(self) -> Iterator[tuple[str, dict[str, Any]]]:
        """Yield (file_path, data_dict) for every row in the database."""
        with self._lock:
            rows = self.conn.execute(
                "SELECT * FROM library_cache"
            ).fetchall()
        for row in rows:
            yield (row["path"], self._row_to_dict(row))

    def __len__(self) -> int:
        with self._lock:
            return self.conn.execute(
                "SELECT COUNT(*) FROM library_cache"
            ).fetchone()[0]

    # ── public write API ───────────────────────────────────────────────

    def put(self, file_path: str, data: dict[str, Any]) -> None:
        """Insert or replace a cache entry for *file_path*."""
        try:
            mtime = os.path.getmtime(file_path)
        except OSError:
            return

        track_uuid = data.get("_track_uuid") or data.get("uuid") or str(uuid.uuid4())
        ch = data.get("_content_hash") or data.get("content_hash") or _content_hash(file_path)

        with self._lock:
            self.conn.execute(
                """INSERT OR REPLACE INTO library_cache
                   (path, uuid, title, artist, album, album_artist,
                    genre, duration_s, size, track_num, source_type,
                    bitrate, sample_rate, cover_path, thumbnail_url,
                    play_count, rating, last_played, skip_count,
                    mtime, content_hash, updated_at)
                   VALUES (?,?,?,?,?,?,
                           ?,?,?,?,?,
                           ?,?,?,?,
                           ?,?,?,?,
                           ?,?,cast(strftime('%s','now') as int))""",
                (
                    file_path,
                    track_uuid,
                    data.get("title", ""),
                    data.get("artist", ""),
                    data.get("album", ""),
                    data.get("album_artist", ""),
                    data.get("genre", ""),
                    data.get("duration_s", 0) or data.get("duration", 0),
                    data.get("size", 0),
                    data.get("track_num", 0) or data.get("track_number", 0),
                    data.get("source_type", ""),
                    data.get("bitrate", 0),
                    data.get("sample_rate", 44100),
                    data.get("cover_path", ""),
                    data.get("thumbnail_url", ""),
                    data.get("play_count", 0),
                    data.get("rating", 0),
                    data.get("last_played", 0),
                    data.get("skip_count", 0),
                    mtime,
                    ch,
                ),
            )
            self._backup()

    def update_play_stats(self, file_path: str,
                          play_count: int | None = None,
                          rating: int | None = None,
                          last_played: int | None = None,
                          skip_count: int | None = None) -> None:
        """Update only the play-stat fields for *file_path*."""
        fields = []
        vals: list[Any] = []
        for k, v in [("play_count", play_count), ("rating", rating),
                      ("last_played", last_played), ("skip_count", skip_count)]:
            if v is not None:
                fields.append(f"{k}=?")
                vals.append(v)
        if not fields:
            return
        fields.append("updated_at=cast(strftime('%s','now') as int)")
        with self._lock:
            self.conn.execute(
                f"UPDATE library_cache SET {', '.join(fields)} WHERE path=?",
                (*vals, file_path),
            )
            self._backup()

    def remove(self, file_path: str) -> None:
        with self._lock:
            self.conn.execute(
                "DELETE FROM library_cache WHERE path=?", (file_path,),
            )
            self._backup()

    def clear(self) -> None:
        with self._lock:
            self.conn.execute("DELETE FROM library_cache")
            self._backup()

    def save(self) -> None:
        """Explicit commit + backup."""
        with self._lock:
            self.conn.commit()
            self._backup()

    # ── play-counts delta deduplication ──────────────────────────────────

    def get_last_synced_delta(self, firewire_id: str) -> dict[str, dict[str, int]]:
        """Return the delta dict that was last committed for this iPod.

        Returns an empty dict if no sync has happened yet.
        """
        with self._lock:
            row = self.conn.execute(
                "SELECT last_synced_delta FROM playcounts_log WHERE firewire_id=?",
                (firewire_id,),
            ).fetchone()
        if row and row["last_synced_delta"]:
            try:
                return json.loads(row["last_synced_delta"])
            except json.JSONDecodeError:
                pass
        return {}

    def set_last_synced_delta(self, firewire_id: str,
                              delta: dict[str, dict[str, int]]) -> None:
        """Store the delta that was committed during the last sync."""
        blob = json.dumps(delta, sort_keys=True, separators=(",", ":"))
        with self._lock:
            self.conn.execute(
                "INSERT OR REPLACE INTO playcounts_log (firewire_id, last_synced_delta) "
                "VALUES (?, ?)",
                (firewire_id, blob),
            )
            self._backup()

    # ── backup rotation ────────────────────────────────────────────────

    def _backup(self) -> None:
        """Rotate database backup files after each write.

        Commits the current transaction first so the backup connection
        can checkpoint the WAL without blocking.

        Keeps two historical copies:
          library_cache.db           ← current
          library_cache.db.backup    ← previous version
          library_cache.db.bak       ← version before that
        """
        # Commit so the second connection can safely checkpoint.
        self.conn.commit()

        db = self._path
        b1 = db + ".backup"
        b2 = db + ".bak"

        try:
            bak_conn = sqlite3.connect(db)
            bak_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
            bak_conn.close()
        except Exception as exc:
            logger.debug("Checkpoint failed: %s", exc)

        if os.path.exists(b1):
            try:
                os.replace(b1, b2)
            except OSError:
                pass
        try:
            import shutil
            if os.path.exists(db):
                shutil.copy2(db, b1)
        except OSError as exc:
            logger.debug("Backup rotation failed: %s", exc)

    # ── internal helpers ───────────────────────────────────────────────

    @staticmethod
    def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
        keys = row.keys()
        d = dict(zip(keys, row))
        d.pop("updated_at", None)
        alias = {
            "duration_s": "duration",
            "track_num": "track_number",
        }
        for old_k, new_k in alias.items():
            if old_k in d and new_k not in d:
                d[new_k] = d[old_k]
        return d

    # ── JSON migration  legacy→SQLite ──────────────────────────────────

    def _migrate_old(self) -> None:
        """One-shot migration from the old JSON cache file."""
        global _migrated_flag
        if _migrated_flag:
            return

        old_dir = xdg_data_path()
        old_path = os.path.join(
            os.path.dirname(old_dir),  # parent: neo-pod-desktop
            ".cache", "neo-pod-desktop", _OLD_JSON_FILE,
        )
        # also try the actual XDG cache dir
        from xdg_base import xdg_cache_path
        alt_path = os.path.join(xdg_cache_path(), _OLD_JSON_FILE)

        for candidate in (old_path, alt_path):
            if os.path.exists(candidate):
                self._do_migrate_json(candidate)
                break

        _migrated_flag = True

    def _do_migrate_json(self, json_path: str) -> None:
        """Read old JSON and insert all entries."""
        try:
            with open(json_path, "r", encoding="utf-8") as f:
                old_data = json.load(f)
        except (json.JSONDecodeError, OSError) as exc:
            logger.warning("JSON migration source corrupt: %s", exc)
            return

        count = 0
        for file_path, entry in old_data.items():
            if not isinstance(entry, dict):
                continue
            mtime = entry.get("_mtime", 0)
            if not mtime:
                try:
                    mtime = os.path.getmtime(file_path)
                except OSError:
                    continue
            entry["_track_uuid"] = entry.get("_track_uuid") or str(uuid.uuid4())
            entry["_content_hash"] = entry.get("_content_hash") or _content_hash(file_path)
            with self._lock:
                self.conn.execute(
                    """INSERT OR IGNORE INTO library_cache
                       (path, uuid, title, artist, album, album_artist,
                        genre, duration_s, size, track_num, source_type,
                        bitrate, sample_rate, cover_path, thumbnail_url,
                        play_count, rating, last_played, skip_count,
                        mtime, content_hash, updated_at)
                       VALUES (?,?,?,?,?,?,
                               ?,?,?,?,?,
                               ?,?,?,?,
                               ?,?,?,?,
                               ?,?,cast(strftime('%s','now') as int))""",
                    (
                        file_path,
                        entry.get("_track_uuid", ""),
                        entry.get("title", ""),
                        entry.get("artist", ""),
                        entry.get("album", ""),
                        entry.get("album_artist", ""),
                        entry.get("genre", ""),
                        entry.get("duration_s", 0) or entry.get("duration", 0),
                        entry.get("size", 0),
                        entry.get("track_num", 0) or entry.get("track_number", 0),
                        entry.get("source_type", ""),
                        entry.get("bitrate", 0),
                        entry.get("sample_rate", 44100),
                        entry.get("cover_path", ""),
                        entry.get("thumbnail_url", ""),
                        entry.get("play_count", 0),
                        entry.get("rating", 0),
                        entry.get("last_played", 0),
                        entry.get("skip_count", 0),
                        mtime,
                        entry.get("_content_hash", ""),
                    ),
                )
                count += 1
        self.conn.commit()
        self._backup()

        # Rename old file so we don't re-migrate
        try:
            os.rename(json_path, json_path + ".migrated")
            logger.info(
                "Migrated %d entries from %s → SQLite",
                count, json_path,
            )
        except OSError:
            pass
