// Drift database for Minstrel's offline cache (#357 plan B).
//
// Two cache layers in one database:
//   - Metadata cache (CachedArtists, CachedAlbums, CachedTracks,
//     CachedLikes, CachedPlaylists, CachedPlaylistTracks) — populated
//     by SyncController via /api/library/sync
//   - Audio cache index (AudioCacheIndex) — owned by AudioCacheManager
//
// Plus SyncMetadata holding the latest sync cursor.
//
// All entity ids are TEXT (server-side UUIDs serialized as strings).
// build_runner generates db.g.dart from this file; it's gitignored and
// regenerated in CI.

import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';

part 'db.g.dart';

class CachedArtists extends Table {
  TextColumn get id => text()();
  TextColumn get name => text()();
  TextColumn get sortName => text()();
  TextColumn get mbid => text().nullable()();
  TextColumn get artistThumbPath => text().nullable()();
  TextColumn get artistFanartPath => text().nullable()();
  DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
  @override
  Set<Column> get primaryKey => {id};
}

class CachedAlbums extends Table {
  TextColumn get id => text()();
  TextColumn get artistId => text()();
  TextColumn get title => text()();
  TextColumn get sortTitle => text()();
  TextColumn get releaseDate => text().nullable()();
  TextColumn get coverPath => text().nullable()();
  TextColumn get mbid => text().nullable()();
  DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
  @override
  Set<Column> get primaryKey => {id};
}

class CachedTracks extends Table {
  TextColumn get id => text()();
  TextColumn get albumId => text()();
  TextColumn get artistId => text()();
  TextColumn get title => text()();
  IntColumn get durationMs => integer().withDefault(const Constant(0))();
  IntColumn get trackNumber => integer().nullable()();
  IntColumn get discNumber => integer().nullable()();
  TextColumn get filePath => text().nullable()();
  TextColumn get fileFormat => text().nullable()();
  TextColumn get genre => text().nullable()();
  DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
  @override
  Set<Column> get primaryKey => {id};
}

class CachedLikes extends Table {
  TextColumn get userId => text()();
  TextColumn get entityType => text()(); // 'track' | 'album' | 'artist'
  TextColumn get entityId => text()();
  DateTimeColumn get likedAt => dateTime().withDefault(currentDateAndTime)();
  @override
  Set<Column> get primaryKey => {userId, entityType, entityId};
}

class CachedPlaylists extends Table {
  TextColumn get id => text()();
  TextColumn get userId => text()();
  TextColumn get name => text()();
  TextColumn get description => text().withDefault(const Constant(''))();
  BoolColumn get isPublic => boolean().withDefault(const Constant(false))();
  TextColumn get coverPath => text().nullable()();
  IntColumn get trackCount => integer().withDefault(const Constant(0))();
  IntColumn get durationSec => integer().withDefault(const Constant(0))();
  /// Server's system_variant: null for user playlists, "for_you" /
  /// "songs_like_artist" / "discover" for system-generated mixes.
  /// Added in schemaVersion 2 to let the add-to-playlist sheet filter
  /// out system playlists locally without a REST round-trip.
  TextColumn get systemVariant => text().nullable()();
  DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
  @override
  Set<Column> get primaryKey => {id};
}

class CachedPlaylistTracks extends Table {
  TextColumn get playlistId => text()();
  TextColumn get trackId => text()();
  IntColumn get position => integer().withDefault(const Constant(0))();
  @override
  Set<Column> get primaryKey => {playlistId, trackId};
}

/// One row per fully-downloaded audio file. `source` drives tiered LRU
/// eviction; `incidental` evicts first, `manual` last.
class AudioCacheIndex extends Table {
  TextColumn get trackId => text()();
  TextColumn get path => text()();
  IntColumn get sizeBytes => integer()();
  DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
  /// When the track was last played. Drives the offline "Recently
  /// played" ordering and rolling-bucket LRU eviction. Distinct from
  /// cachedAt (download time). Nullable for pre-schema-9 rows until
  /// the next play touches them (migration backfills to cachedAt).
  DateTimeColumn get lastPlayedAt => dateTime().nullable()();
  TextColumn get source => textEnum<CacheSource>()();
  @override
  Set<Column> get primaryKey => {trackId};
}

/// Single-row table holding the latest sync cursor.
class SyncMetadata extends Table {
  IntColumn get id => integer().withDefault(const Constant(1))();
  IntColumn get cursor => integer().withDefault(const Constant(0))();
  DateTimeColumn get lastSyncAt => dateTime().nullable()();
  @override
  Set<Column> get primaryKey => {id};
}

/// Single-row cache of the /api/me/history response. Mirrors the
/// CachedHomeSnapshot pattern: opening the History tab in the Library
/// screen yields the last-known page immediately while a fresh REST
/// pull lands underneath via SWR. Also makes basic offline scrollback
/// possible — the last fetched page survives both app restart and
/// loss of connectivity. Schema 4+.
class CachedHistorySnapshot extends Table {
  IntColumn get id => integer().withDefault(const Constant(1))();
  TextColumn get json => text()();
  DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
  @override
  Set<Column> get primaryKey => {id};
}

/// Single-row cache of the /api/me/system-playlists-status response.
/// The home Playlists row reads this every render to pick between
/// real cards and placeholder cards for For-You / Discover / Songs-
/// like slots, so a fresh-mount cold fetch produces a visible
/// "building / pending / failed" flicker. Storing the last result
/// as a JSON blob means the home renders with the prior status
/// instantly, then SWR refreshes underneath. Schema 7+.
class CachedSystemPlaylistsStatus extends Table {
  IntColumn get id => integer().withDefault(const Constant(1))();
  TextColumn get json => text()();
  DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
  @override
  Set<Column> get primaryKey => {id};
}

/// Section→position→entity-id index for the home screen, populated
/// from the per-item discovery endpoint `/api/home/index`. Each row
/// pins one tile slot to an entity; the actual entity data lives in
/// cached_albums / cached_artists / cached_tracks. The home screen
/// reads this table to know the layout, then hydrates each tile
/// against the entity tables (per-item rendering). Schema 6+.
class CachedHomeIndex extends Table {
  /// One of: 'recently_added_albums', 'rediscover_albums',
  /// 'rediscover_artists', 'most_played_tracks', 'last_played_artists'.
  /// Mirrors the keys /api/home and /api/home/index emit.
  TextColumn get section => text()();
  IntColumn get position => integer()();
  /// One of: 'album', 'artist', 'track'. Used to dispatch hydration
  /// to the right per-entity endpoint.
  TextColumn get entityType => text()();
  TextColumn get entityId => text()();
  DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
  @override
  Set<Column> get primaryKey => {section, position};
}

/// Outbound mutation queue for offline-resilient REST calls. Likes,
/// quarantine flag/unflag, playlist appendTracks, Lidarr request
/// create/cancel all enqueue here on REST failure so the user's
/// intent persists across network loss. MutationReplayer drains on
/// connectivity transitions and a 1-minute periodic tick; entries
/// with attempts >= 5 are skipped (the server treats them as
/// permanent failures and library_changes sync will correct any
/// resulting drift drift on next sync). Schema 8+.
class CachedMutations extends Table {
  IntColumn get id => integer().autoIncrement()();
  /// Stable kind string (see mutation_queue.dart constants). The
  /// replayer looks up the handler in a kind→Function map; unknown
  /// kinds are dropped to avoid getting stuck.
  TextColumn get kind => text()();
  /// JSON-encoded payload — args needed to replay the REST call.
  /// Shape depends on kind; see mutation_queue.dart.
  TextColumn get payload => text()();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
  /// When the last drain attempt fired against this row. Null until
  /// the first attempt. Useful for diagnostic queries.
  DateTimeColumn get lastAttemptAt => dateTime().nullable()();
  IntColumn get attempts => integer().withDefault(const Constant(0))();
}

/// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of
/// /api/quarantine/mine — fully denormalized (track + album + artist
/// fields inline) because the Hidden tab renders straight from this row
/// without joining other tables. Columnar (vs JSON blob) so flag/unflag
/// can do row-level INSERT/DELETE; the drift watch() emission feeds
/// MyQuarantineController state directly. Schema 5+.
class CachedQuarantineMine extends Table {
  TextColumn get trackId => text()();
  TextColumn get reason => text()();
  TextColumn get notes => text().nullable()();
  TextColumn get createdAt => text()();
  TextColumn get trackTitle => text()();
  IntColumn get trackDurationMs => integer().withDefault(const Constant(0))();
  TextColumn get albumId => text()();
  TextColumn get albumTitle => text()();
  TextColumn get albumCoverArtPath => text().nullable()();
  TextColumn get artistId => text()();
  TextColumn get artistName => text()();
  DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
  @override
  Set<Column> get primaryKey => {trackId};
}

/// Single-row snapshot of the last playback session — queue (TrackRef
/// JSON), current index, position, and #415 source. Lets a torn-down
/// session (the #52 idle/dismissed teardown) resume on next launch;
/// without it the headset / lock-screen play button has nothing to
/// resume. Mirrors the CachedHomeSnapshot single-row JSON pattern.
/// Schema 10+.
class CachedResumeState extends Table {
  IntColumn get id => integer().withDefault(const Constant(1))();
  TextColumn get json => text()();
  DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
  @override
  Set<Column> get primaryKey => {id};
}

enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }

@DriftDatabase(tables: [
  CachedArtists,
  CachedAlbums,
  CachedTracks,
  CachedLikes,
  CachedPlaylists,
  CachedPlaylistTracks,
  AudioCacheIndex,
  SyncMetadata,
  CachedHistorySnapshot,
  CachedQuarantineMine,
  CachedHomeIndex,
  CachedSystemPlaylistsStatus,
  CachedMutations,
  CachedResumeState,
])
class AppDb extends _$AppDb {
  AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));

  @override
  int get schemaVersion => 11;

  @override
  MigrationStrategy get migration => MigrationStrategy(
        onCreate: (m) => m.createAll(),
        onUpgrade: (m, from, to) async {
          if (from < 2) {
            // Schema 2: add CachedPlaylists.systemVariant. Existing
            // rows get null and the next sync rebuilds them with the
            // server's system_variant value.
            await m.addColumn(cachedPlaylists, cachedPlaylists.systemVariant);
            // Reset cursor so the next /api/library/sync re-emits all
            // playlists; otherwise the new column would stay null on
            // pre-existing rows until they happen to change server-side.
            await customStatement('UPDATE sync_metadata SET cursor = 0');
          }
          if (from < 3) {
            // Schema 3: cached_home_snapshot (legacy drift-first home).
            // The table + its homeProvider/JSON encoders were removed
            // in #406; recreated here as raw SQL so this historical
            // step still compiles without the generated table symbol.
            // The from<11 step below drops it.
            await customStatement(
              'CREATE TABLE IF NOT EXISTS "cached_home_snapshot" '
              '("id" INTEGER NOT NULL DEFAULT 1, "json" TEXT, '
              '"updated_at" INTEGER, PRIMARY KEY ("id"));',
            );
          }
          if (from < 4) {
            // Schema 4: cached_history_snapshot for drift-first
            // History tab. Same pattern as cached_home_snapshot —
            // empty on upgrade; first /api/me/history fetch populates.
            await m.createTable(cachedHistorySnapshot);
          }
          if (from < 5) {
            // Schema 5: cached_quarantine_mine for drift-first Hidden
            // tab. Empty on upgrade; first /api/quarantine/mine fetch
            // populates. Optimistic flag/unflag also writes here.
            await m.createTable(cachedQuarantineMine);
          }
          if (from < 6) {
            // Schema 6: cached_home_index for per-item rendering.
            // Empty on upgrade; first /api/home/index fetch populates.
            // cached_home_snapshot stays in place for now — older
            // builds still read from it as a fallback path.
            await m.createTable(cachedHomeIndex);
          }
          if (from < 7) {
            // Schema 7: cached_system_playlists_status. Single-row
            // snapshot of /api/me/system-playlists-status used by the
            // home Playlists row. Empty on upgrade; first fetch
            // populates.
            await m.createTable(cachedSystemPlaylistsStatus);
          }
          if (from < 8) {
            // Schema 8: cached_mutations outbound queue. Empty on
            // upgrade; populated by controllers when REST calls fail.
            await m.createTable(cachedMutations);
          }
          if (from < 9) {
            // Schema 9 (#427 S2): two-bucket cache. lastPlayedAt
            // gives the offline "Recently played" view a real
            // recency signal (cachedAt is download time, not play
            // time). Nullable — backfilled to cachedAt so existing
            // rows order sensibly until next play touches them.
            await m.addColumn(audioCacheIndex, audioCacheIndex.lastPlayedAt);
            await customStatement(
              'UPDATE audio_cache_index SET last_played_at = cached_at',
            );
          }
          if (from < 10) {
            // Schema 10 (#54): cached_resume_state — single-row last-
            // session snapshot for resume-on-launch. Empty on upgrade;
            // first persist populates it.
            await m.createTable(cachedResumeState);
          }
          if (from < 11) {
            // Schema 11 (#406): drop the legacy cached_home_snapshot
            // table. The home screen now renders solely from the
            // per-item cached_home_index path; the legacy homeProvider
            // + its JSON encoders were removed.
            await customStatement(
              'DROP TABLE IF EXISTS "cached_home_snapshot";',
            );
          }
        },
      );
}
