// Package datamap is the catalog of everything YellowJacket persists.
//
// It exists because deletion logic used to be written per call site and
// lived far from the thing being deleted.  Nobody adding a table could
// know the full set of places that needed updating, so tables leaked
// (rows nothing ever removed), files leaked (thumbnails whose database
// row was gone), and in one case a new table's foreign key silently made
// libraries unremovable.
//
// Every table, view, and on-disk asset directory is classified along two
// axes that between them determine every policy worth having:
//
//   - Kind — what it costs to lose the data.
//   - Lifetime — how rows or files are removed.
//
// The catalog is plain data with no dependency on any service, so tests
// can assert it against a live schema.  The rule that gives it teeth:
// every table in the database must be claimed by exactly one entry here,
// enforced by TestCatalogCoversSchema.  A new table fails the build until
// somebody states what it is and how it dies.
package datamap

import "strings"

// Kind classifies persisted data by what losing it costs.
type Kind string

const (
	// Owned data is a projection of the user's audio files.  The files
	// on disk are the source of truth; a rescan rebuilds all of it.
	Owned Kind = "owned"

	// Authored data was created by the user and exists nowhere else.
	// Losing it is unrecoverable data loss — it must never be deleted
	// as a side effect of anything.
	Authored Kind = "authored"

	// Derived data is computed from owned data and is cheap to rebuild.
	// It may be deleted freely and must never block deletion of the
	// data it was derived from.
	Derived Kind = "derived"

	// Cache data came from the network or a MusicBrainz dump.  It is
	// rebuildable but expensive — rate limits, or a multi-hour index
	// build — so it is evicted on its own schedule rather than being
	// tied to the lifetime of anything else.
	Cache Kind = "cache"
)

// Lifetime says how rows leave a table.  It is declared here and checked
// against the live schema by TestLifetimesMatchSchema, so a foreign key
// that disagrees with the stated policy fails the tests.
type Lifetime string

const (
	// Cascade means a foreign key with ON DELETE CASCADE removes rows
	// when the parent goes.  No application code required.
	Cascade Lifetime = "cascade"

	// SetNull means a foreign key with ON DELETE SET NULL orphans the
	// row deliberately, keeping it alive without its parent.
	SetNull Lifetime = "set-null"

	// Swept means application code must delete these rows explicitly —
	// either in a removal path or in the maintenance janitor.  Any table
	// with a NO ACTION foreign key must declare this, because such a key
	// blocks its parent's deletion until someone clears the child rows.
	Swept Lifetime = "swept"

	// Retained means rows are never removed automatically.  Only an
	// explicit user action deletes them.
	Retained Lifetime = "retained"
)

// Table is one catalog entry.
type Table struct {
	// Name is the SQL table or view name.
	Name string
	// Kind is what the data costs to lose.
	Kind Kind
	// Lifetime is how rows are removed.
	Lifetime Lifetime
	// FTS marks an FTS5 virtual table, which SQLite backs with four
	// shadow tables (_config, _data, _docsize, _idx).  Those are
	// implementation detail and are resolved to this entry.
	FTS bool
	// Note records why the classification is what it is, particularly
	// where a table is not purely one kind.
	Note string
}

// ftsShadowSuffixes are the tables SQLite creates behind an FTS5 virtual
// table.  They belong to their parent and are never catalogued directly.
var ftsShadowSuffixes = []string{
	"_config", "_data", "_docsize", "_idx",
}

// internalTables are SQLite's own bookkeeping, outside our control.
var internalTables = map[string]bool{
	"sqlite_sequence": true,
	"sqlite_stat1":    true,
	"sqlite_stat4":    true,
}

// tables is the catalog.  Keep it alphabetical.
var tables = []Table{
	{
		Name: "albums", Kind: Owned, Lifetime: Swept,
		Note: "Albums as named by file tags — the local counterpart of a " +
			"catalog release group, which is a different thing and lives " +
			"in explore_index. Swept when the last file on one goes.",
	},
	{
		Name: "artist_enrichment", Kind: Derived, Lifetime: Retained,
		Note: "Which catalog enrichment passes have run for an artist. " +
			"Derived: losing it re-runs the fetches, which cost time and " +
			"someone else's rate limit but no data. Retained because a " +
			"stale mark for an artist no longer owned is harmless — the " +
			"backfill only ever asks about artists the library has.",
	},
	{
		Name: "artist_images", Kind: Cache, Lifetime: Swept,
		Note: "Artist photos from fanart.tv/MusicBrainz. Rows point at " +
			"files under the artist-images directory; the janitor sweeps " +
			"both together.",
	},
	{
		Name: "artist_metadata", Kind: Cache, Lifetime: Swept,
		Note: "Fetched artist bios and metadata. No TTL — swept when the " +
			"artist is no longer referenced.",
	},
	{
		Name: "artists", Kind: Owned, Lifetime: Swept,
		Note: "Artists parsed from file tags. Orphan-swept.",
	},
	{
		Name: "audio_files", Kind: Owned, Lifetime: Swept,
		Note: "MIXED KIND. One row per file, carrying its tags: mostly an " +
			"owned projection of what is on disk, but play_count, " +
			"last_played and tag_status are authored and exist nowhere " +
			"else. Deleting a row to rebuild it destroys that authored " +
			"state — which is why a file rename currently loses play " +
			"counts. See the data architecture plan.",
	},
	{
		Name: "cover_art", Kind: Owned, Lifetime: Swept,
		Note: "Extracted embedded artwork. file_path names the original " +
			"only; the sized variants beside it are derived filenames and " +
			"must be expanded when deleting (see library.coverArtFileSet).",
	},
	{
		Name: "download_downloads", Kind: Authored, Lifetime: Cascade,
		Note: "One row per 'go find me this', i.e. one search-and-grab " +
			"attempt. Cascades from libraries, and cascades onward to " +
			"download_items. Terminal rows are history the user clears " +
			"explicitly.",
	},
	{
		Name: "download_items", Kind: Authored, Lifetime: Cascade,
		Note: "One grab attempt per row, with the ranked candidate stored " +
			"as JSON. Cascades from download_downloads. The candidate blob " +
			"is kept rather than re-derived because a provider's result " +
			"set is ephemeral — the peer that had the files may be gone, " +
			"and the row still has to explain why it was chosen.",
	},
	{
		Name: "download_providers", Kind: Authored, Lifetime: Retained,
		Note: "Download clients the user connected. Removed only by the " +
			"user. Holds no secrets: API keys live in a 0600 file keyed " +
			"by this row's id, so the table can be dumped into a bug " +
			"report without redaction.",
	},
	{
		Name: "download_requests", Kind: Authored, Lifetime: Cascade,
		Note: "The durable request list: one MBID per row, plus retry " +
			"bookkeeping. Cascades from libraries, and from a parent " +
			"artist request to the album requests it derived. Unlike " +
			"download_downloads these are not history — a request " +
			"outlives every download attempt made on it and is only " +
			"removed by the user or by the library coming to own what it " +
			"names.",
	},
	{
		Name: "explore_champion_fts", Kind: Cache, Lifetime: Retained, FTS: true,
		Note: "Full-text index over the champion entities of the " +
			"MusicBrainz dump. Rebuilt only by a full index build.",
	},
	{
		Name: "artist_credit_part", Kind: Cache, Lifetime: Retained,
		Note: "The decomposition of a multi-artist credit, from the " +
			"MusicBrainz dump: one row per credited artist, with the " +
			"name as credited and the join phrase that follows it. " +
			"Arrives with the downloaded artifact, so rebuilding it " +
			"costs a dump stream and it is never swept.",
	},
	{
		Name: "artist_credit_ref", Kind: Cache, Lifetime: Retained,
		Note: "Which credit a catalog recording or release group is " +
			"credited to. Present only for multi-artist credits; " +
			"absence means one artist, which explore_index already " +
			"describes. Ships and dies with artist_credit_part.",
	},
	{
		Name: "explore_index", Kind: Cache, Lifetime: Retained,
		Note: "The offline MusicBrainz search index. Rebuilding costs a " +
			"~205GB dump stream, so it is never swept automatically.",
	},
	{
		Name: "explore_index_fts", Kind: Cache, Lifetime: Retained, FTS: true,
		Note: "Full-text index over explore_index.",
	},
	{
		Name: "explore_index_meta", Kind: Cache, Lifetime: Retained,
		Note: "Build metadata for explore_index: dump version, coverage " +
			"tiers, last refresh.",
	},
	{
		Name: "excluded_paths", Kind: Authored, Lifetime: Cascade,
		Note: "Paths the user removed from the library, which the scanner " +
			"must not import again. Authored: it is a decision, not " +
			"derivable from disk. Cascades with its library, and a full " +
			"rescan clears it \u2014 the only way back for a path removed by " +
			"mistake.",
	},
	{
		Name: "file_genres", Kind: Owned, Lifetime: Swept,
		Note: "Genres per file. The one many-to-many in the local library " +
			"that really is one. It cascades with the *file*, but the " +
			"genre_id key is NO ACTION, so a genre cannot be deleted " +
			"while a link survives — the genre sweep runs after the file " +
			"sweep for that reason, and only deletes genres nothing " +
			"references.",
	},
	{
		Name: "file_types", Kind: Derived, Lifetime: Retained,
		Note: "Static lookup rows seeded from code, not user data.",
	},
	{
		Name: "genres", Kind: Owned, Lifetime: Swept,
		Note: "Genres parsed from file tags. Orphan-swept.",
	},
	{
		Name: "http_cache", Kind: Cache, Lifetime: Swept,
		Note: "Cached HTTP responses with a TTL. Reads filter on " +
			"expires_at; the janitor deletes expired rows.",
	},
	{
		Name: "job_state", Kind: Authored, Lifetime: Retained,
		Note: "Persisted background-job state, including scans the user " +
			"paused. Represents user intent, so it survives restarts.",
	},
	{
		Name: "libraries", Kind: Authored, Lifetime: Retained,
		Note: "The directories the user chose. Removed only by explicit " +
			"user action via RemoveLibrary.",
	},
	{
		Name: "lyrics", Kind: Cache, Lifetime: Cascade,
		Note: "MIXED KIND, and it says which: source='tag' is Owned (any " +
			"rescan reads it back off the file) and source='lrclib' is " +
			"Cache (re-fetching it is network traffic and someone else's " +
			"rate limit). These used to be one untyped column on " +
			"recordings, in a table classified Owned, so 24,294 rows in a " +
			"real library could not say which of the two they were.",
	},
	{
		Name: "lyrics_index", Kind: Derived, Lifetime: Retained, FTS: true,
		Note: "Full-text index over embedded and fetched lyrics. Rebuilt " +
			"from owned files plus the LRCLIB backfill.",
	},
	{
		Name: "play_history", Kind: Authored, Lifetime: Cascade,
		Note: "Listening history. Authored, but intentionally cascades " +
			"with its track — history for a file no longer in the library " +
			"has nothing to point at.",
	},
	{
		Name: "player_state", Kind: Authored, Lifetime: Retained,
		Note: "Volume, repeat and shuffle modes, last position.",
	},
	{
		Name: "playlist_tracks", Kind: Authored, Lifetime: SetNull,
		Note: "Playlist membership. Deliberately survives its track: " +
			"audio_file_id is nulled and phantom_* columns preserve the " +
			"entry so a rescan can re-link it.",
	},
	{
		Name: "playlists", Kind: Authored, Lifetime: Retained,
		Note: "User-created playlists, including smart playlist rules.",
	},
	{
		Name: "queue", Kind: Authored, Lifetime: Retained,
		Note: "The play queue's own state (current position, source).",
	},
	{
		Name: "queue_tracks", Kind: Authored, Lifetime: Cascade,
		Note: "Queue entries. Cascade with their track; the queue is " +
			"compacted afterwards.",
	},
	{
		Name: "release_to_rg", Kind: Cache, Lifetime: Retained,
		Note: "Release to release-group mapping, captured during a local " +
			"dump import and read by the incremental listen-count " +
			"refresh. Empty unless this install built its own index.",
	},
	{
		Name: "search_clicks", Kind: Authored, Lifetime: Retained,
		Note: "Which results the user picked, used to rank future " +
			"searches. Behavioural but unrecoverable if dropped.",
	},
	{
		Name: "search_index", Kind: Derived, Lifetime: Retained, FTS: true,
		Note: "Contentless FTS5 over the library. Individual rows cannot " +
			"be deleted, so stale entries are tolerated and filtered by " +
			"joining track_metadata; a full rescan rebuilds it.",
	},
	{
		Name: "similar_artist_map", Kind: Cache, Lifetime: Retained,
		Note: "Artist similarity edges derived from the dump.",
	},
	{
		Name: "tagging_candidates", Kind: Derived, Lifetime: Cascade,
		Note: "Scored MusicBrainz candidates for a tagging group. " +
			"Cascades with its tagging_items row.",
	},
	{
		Name: "tagging_items", Kind: Derived, Lifetime: Swept,
		Note: "MIXED KIND. The grouping is derived from folder layout, " +
			"but status and cleared_at record the user's review " +
			"decisions. Its library_id foreign key is NO ACTION, so " +
			"RemoveLibrary must delete these rows explicitly or the " +
			"library cannot be removed at all.",
	},
	{
		Name: "track_metadata", Kind: Derived, Lifetime: Retained,
		Note: "A view joining audio_files to its entity chain. Holds no " +
			"rows of its own.",
	},
}

// directories are the on-disk asset trees under the user data directory.
// Files there have no foreign keys, so nothing removes them implicitly —
// each needs a sweeper in the janitor.
var directories = []Directory{
	{
		Name: "covers", Kind: Derived,
		Note: "Extracted cover art plus generated _sm/_md/_lg variants. " +
			"Live set is cover_art.file_path expanded to its variants.",
	},
	{
		Name: "artist-images", Kind: Cache,
		Note: "Per-artist-MBID directories of fetched photos, a " +
			"primary.jpg with thumbnails, and a .miss marker for artists " +
			"known to have no art. Live set is artist_images.file_path.",
	},
	{
		Name: "cover-art-cache", Kind: Cache,
		Note: "Cover Art Archive images fetched for Explore browsing, " +
			"keyed by release-group MBID. Not tied to owned data at all.",
	},
}

// Directory is an on-disk asset tree owned by the application.
type Directory struct {
	// Name is the directory name under the user data directory.
	Name string
	// Kind is what the files cost to lose.
	Kind Kind
	// Note records what lives there and how the live set is determined.
	Note string
}

// Tables returns the full catalog.
func Tables() []Table {
	out := make([]Table, len(tables))
	copy(out, tables)

	return out
}

// Directories returns the catalogued asset directories.
func Directories() []Directory {
	out := make([]Directory, len(directories))
	copy(out, directories)

	return out
}

// Lookup returns the catalog entry for a table name, resolving FTS
// shadow tables to their parent.  Reports false for SQLite's internal
// tables and for anything not catalogued.
func Lookup(name string) (Table, bool) {
	if internalTables[name] {
		return Table{}, false
	}

	for _, t := range tables {
		if t.Name == name {
			return t, true
		}
	}

	if parent, ok := ftsParent(name); ok {
		return Lookup(parent)
	}

	return Table{}, false
}

// IsInternal reports whether a table is SQLite's own bookkeeping.
func IsInternal(name string) bool {
	return internalTables[name] || strings.HasPrefix(name, "sqlite_")
}

// ftsParent maps an FTS5 shadow table to the virtual table that owns it.
func ftsParent(name string) (string, bool) {
	for _, suffix := range ftsShadowSuffixes {
		if !strings.HasSuffix(name, suffix) {
			continue
		}

		parent := strings.TrimSuffix(name, suffix)

		for _, t := range tables {
			if t.Name == parent && t.FTS {
				return parent, true
			}
		}
	}

	return "", false
}

// ByKind returns every catalogued table of a given kind.
func ByKind(k Kind) []Table {
	var out []Table

	for _, t := range tables {
		if t.Kind == k {
			out = append(out, t)
		}
	}

	return out
}
