// Package download acquires music from user-configured external
// services and imports it into the library.
//
// The services users connect are not the same kind of thing: some
// search, some move bytes, some are whole automation systems we hand a
// request to.  Rather than one interface every adapter half-implements,
// a provider fills one or more of three roles — Searcher, Transporter,
// Delegator — and declares which in its Caps.  The pipeline composes
// them: a search-only provider (Prowlarr) is paired with a transport
// (qBittorrent, SABnzbd) by protocol at grab time, while providers that
// do both (slskd, yt-dlp) pair with themselves.
//
// Nothing here downloads into the library.  Grabs land in a staging
// directory, are verified and tagged against the release the user
// actually asked for, and only then move into library paths.
package download

import (
	"slices"
	"strings"
	"time"
)

// Kind identifies a provider implementation.  It is stored in the
// database and used to look up the constructor in the registry, so
// values are stable strings and never renamed.
type Kind string

// Provider kinds.
const (
	KindSlskd       Kind = "slskd"
	KindYtDlp       Kind = "yt-dlp"
	KindLidarr      Kind = "lidarr"
	KindProwlarr    Kind = "prowlarr"
	KindQBittorrent Kind = "qbittorrent"
	KindSABnzbd     Kind = "sabnzbd"

	// KindFake is an in-memory provider used by tests.  It is never
	// offered in the UI.
	KindFake Kind = "fake"
)

// Protocol is how a candidate's bytes are moved.  Search-only providers
// report it so the pipeline can pick a compatible transport; providers
// that transport their own results use ProtocolDirect.
type Protocol string

// Transport protocols.
const (
	// ProtocolDirect means the finding provider also does the fetch.
	ProtocolDirect  Protocol = "direct"
	ProtocolTorrent Protocol = "torrent"
	ProtocolUsenet  Protocol = "usenet"
)

// Caps declares which roles a provider fills and which optional
// behaviours it supports.  The frontend renders controls from this
// rather than switching on Kind, so a provider that gains resume
// support later needs no frontend change.
type Caps struct {
	// Roles.
	CanSearch    bool `json:"canSearch"`
	CanTransport bool `json:"canTransport"`
	CanDelegate  bool `json:"canDelegate"`

	// CanList marks a provider that keeps a persistent wanted list of
	// its own, which the reconciler mirrors this app's list into.
	CanList bool `json:"canList"`

	// Optional behaviours.
	CanResume   bool `json:"canResume"`
	CanCancel   bool `json:"canCancel"`
	ReportsSize bool `json:"reportsSize"`

	// Protocols this provider can transport.  Empty for providers that
	// only fetch their own search results.
	Transports []Protocol `json:"transports"`
}

// Handles reports whether the provider can transport the given protocol.
func (c Caps) Handles(p Protocol) bool {
	return slices.Contains(c.Transports, p)
}

// Download is one search-and-grab attempt: it searches, it grabs, it
// succeeds or fails, and then it is history.  A Download that carries a
// MusicBrainz anchor is far more reliable than a free-text one, because
// the anchor gives the import step an expected tracklist to match
// against — so the pipeline records which it got and refuses to
// auto-pick without one.
//
// A Download is not the same thing as a Request (request.go): a
// Request is durable and outlives every attempt made on its behalf,
// while a Download is one such attempt and is disposable.
type Download struct {
	ID string `json:"id"`

	// Anchors.  Any may be empty; all empty means free-text.
	ReleaseMBID      string `json:"releaseMbid,omitempty"`
	ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`

	// RecordingMBID anchors a single-track download.  Its Expected holds
	// exactly that one track, which is what lets a track download be
	// scored — and therefore auto-picked — on the same footing as an
	// album.
	RecordingMBID string `json:"recordingMbid,omitempty"`

	// RequestID links back to the durable Request row this download was
	// raised for or attached to, or 0 for a free-text download with
	// nothing stable to attach to.  The reconciler and manual anchored
	// downloads both write the outcome back through it.
	RequestID int64 `json:"requestId,omitempty"`

	// Source records where the download came from, for the downloads
	// list.  Empty means "manual".
	Source string `json:"source,omitempty"`

	// Display and query text.  Artist/Album are what searches are built
	// from; Query overrides them when the user typed something raw.
	Artist string `json:"artist"`
	Album  string `json:"album"`
	Query  string `json:"query,omitempty"`

	// Expected is the tracklist the anchor resolves to, used for
	// completeness scoring and for the autotag match at import.  Empty
	// for free-text downloads.
	Expected []ExpectedTrack `json:"expected,omitempty"`

	// LibraryID is the library imported files belong to.
	LibraryID int64 `json:"libraryId"`

	CreatedAt time.Time `json:"createdAt"`
}

// Anchored reports whether the download carries a MusicBrainz ID.  Only
// anchored downloads are eligible for auto-pick.
func (d Download) Anchored() bool {
	return d.ReleaseMBID != "" ||
		d.ReleaseGroupMBID != "" ||
		d.RecordingMBID != ""
}

// SearchText returns the string to hand a provider's search endpoint.
//
// Album titles routinely start with the artist name — self-titled
// albums ("Boston" / "Boston") and titles like "Blank Banshee 0" both
// do — so naively concatenating Artist and Album would search for
// "Blank Banshee Blank Banshee 0". That repeated term is enough to
// return zero results on providers that expect every term to appear
// in a match (Soulseek in particular), so the artist is dropped when
// the album title already leads with it.
func (d Download) SearchText() string {
	if d.Query != "" {
		return d.Query
	}

	if d.Artist != "" && albumLeadsWithArtist(d.Artist, d.Album) {
		return strings.TrimSpace(d.Album)
	}

	return strings.TrimSpace(d.Artist + " " + d.Album)
}

// albumLeadsWithArtist reports whether album starts with artist as a
// whole word, case-insensitively, so it is safe to drop the artist
// from a combined query without losing a real search term. A plain
// substring check would misfire on cases like artist "Air" against
// album "Repair".
func albumLeadsWithArtist(artist, album string) bool {
	a, b := strings.ToLower(strings.TrimSpace(artist)), strings.ToLower(strings.TrimSpace(album))
	if a == "" || !strings.HasPrefix(b, a) {
		return false
	}

	rest := b[len(a):]

	return rest == "" || !isWordChar(rune(rest[0]))
}

// isWordChar reports whether r continues a word for the purposes of
// albumLeadsWithArtist's boundary check.
func isWordChar(r rune) bool {
	return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
}

// ExpectedTrack is one track of the release the user asked for.
type ExpectedTrack struct {
	Position     int    `json:"position"`
	DiscNumber   int    `json:"discNumber"`
	Title        string `json:"title"`
	Artist       string `json:"artist"`
	LengthMillis int64  `json:"lengthMillis"`
}

// Candidate is one acquirable thing a provider found: a Soulseek user's
// folder, a torrent, a YouTube playlist.  Providers fill the descriptive
// fields; the ranker fills Match, Quality and Score.
type Candidate struct {
	// ID is unique within the provider that produced it, and is what
	// gets handed back to Grab.
	ID         string `json:"id"`
	ProviderID int64  `json:"providerId"`
	Kind       Kind   `json:"kind"`

	// Protocol determines which transport can fetch this.
	Protocol Protocol `json:"protocol"`

	// Descriptive.
	Title  string `json:"title"`
	Artist string `json:"artist,omitempty"`
	Origin string `json:"origin,omitempty"` // peer username, indexer name, channel

	Files     []CandidateFile `json:"files"`
	TotalSize int64           `json:"totalSize"`

	// Health is the provider's own availability signal, normalized to
	// 0..1: seeder count for torrents, free upload slots and queue
	// length for Soulseek.  0.5 when the provider has no signal.
	Health float64 `json:"health"`

	// Scores, filled by the ranker.
	Match   MatchScore   `json:"match"`
	Quality QualityScore `json:"quality"`
	Score   float64      `json:"score"`

	// Payload is provider-private data needed to fetch this candidate
	// (magnet URI, NZB URL, slskd file list).  Never shown to the user.
	Payload map[string]string `json:"-"`
}

// CandidateFile is one file inside a candidate.  Soulseek and torrent
// results give paths and sizes but no tags, so Format and duration are
// inferred from the path and size where possible.
type CandidateFile struct {
	Path      string `json:"path"`
	Size      int64  `json:"size"`
	Format    Format `json:"format"`
	Bitrate   int    `json:"bitrate,omitempty"` // kbps, 0 when unknown
	IsAudio   bool   `json:"isAudio"`
	MatchedTo int    `json:"matchedTo,omitempty"` // expected track position
}

// Format is a normalized audio container/codec name.
type Format string

// Audio formats, ordered by the quality ranking in formatRank.
const (
	FormatUnknown Format = ""
	FormatFLAC    Format = "flac"
	FormatALAC    Format = "alac"
	FormatWAV     Format = "wav"
	FormatMP3     Format = "mp3"
	FormatAAC     Format = "aac"
	FormatOGG     Format = "ogg"
	FormatOpus    Format = "opus"
	FormatWMA     Format = "wma"
)

// Lossless reports whether the format preserves the source exactly.
func (f Format) Lossless() bool {
	return f == FormatFLAC || f == FormatALAC || f == FormatWAV
}

// Supported reports whether the player can decode this format.  Grabs
// of unsupported formats are still allowed — the user may want them —
// but they rank below playable ones.
func (f Format) Supported() bool {
	switch f {
	case FormatMP3, FormatFLAC, FormatOGG, FormatWAV:
		return true
	case FormatUnknown, FormatALAC, FormatAAC, FormatOpus, FormatWMA:
		return false
	default:
		return false
	}
}

// MatchScore answers "is this the release the user asked for?"  It is
// deliberately separate from QualityScore: a perfect match at 128kbps
// and a mediocre match in FLAC are different failures, and collapsing
// them into one number makes the ranking impossible to explain.
type MatchScore struct {
	// Overall is 0..1.
	Overall float64 `json:"overall"`

	TitleFit     float64 `json:"titleFit"`     // filenames vs expected titles
	ArtistFit    float64 `json:"artistFit"`    // path/origin vs expected artist
	AlbumFit     float64 `json:"albumFit"`     // folder name vs album title
	Completeness float64 `json:"completeness"` // audio files vs expected count

	// Anchored records whether an MBID drove this score.  Unanchored
	// matches are capped, because there is nothing to be right about.
	Anchored bool `json:"anchored"`
}

// QualityScore answers "is this a good copy?".
type QualityScore struct {
	// Overall is 0..1.
	Overall float64 `json:"overall"`

	FormatRank float64 `json:"formatRank"` // FLAC > V0 > 320 > lower
	Bitrate    float64 `json:"bitrate"`
	Health     float64 `json:"health"`   // seeders, free slots
	Priority   float64 `json:"priority"` // user's per-provider preference
	// BitrateFit is closeness to the preferred *rate*, which is what
	// the auto-download window is expressed in.  It replaced a
	// `SizeFit` measured in megabytes: a size means nothing without
	// knowing how long the music is, so the same number described a
	// generous single and a suspiciously small boxset.
	BitrateFit float64 `json:"bitrateFit"`

	// Mixed marks a candidate whose files are not all the same format,
	// which usually means a hand-assembled folder rather than a rip.
	Mixed bool `json:"mixed"`
}

// AudioFiles returns only the audio entries of a candidate.
func (c Candidate) AudioFiles() []CandidateFile {
	out := make([]CandidateFile, 0, len(c.Files))

	for _, f := range c.Files {
		if f.IsAudio {
			out = append(out, f)
		}
	}

	return out
}

// State is the lifecycle position of a download item.
type State string

// Download item states.  Searching through Importing are live;
// Complete, Cancelled and Failed are terminal.
const (
	StateSearching State = "searching"
	StateFound     State = "found"
	StateQueued    State = "queued"
	StateGrabbing  State = "grabbing"
	StateVerifying State = "verifying"
	StateTagging   State = "tagging"
	StateImporting State = "importing"
	StateComplete  State = "complete"
	StateCancelled State = "cancelled"
	StateFailed    State = "failed"
)

// IsTerminal reports whether the state means no further progress will
// happen without a new attempt.
func (s State) IsTerminal() bool {
	return s == StateComplete || s == StateCancelled || s == StateFailed
}

// Progress is a transport's periodic report.  Total is 0 when the
// provider cannot say how large the transfer is.
type Progress struct {
	Current int64
	Total   int64
	Phase   string
}

// ProgressFunc receives transport progress.  Implementations must
// tolerate being called from any goroutine and at high frequency.
type ProgressFunc func(Progress)

// Result is what a transport produced.
type Result struct {
	// Dir is the staging directory the files landed in.
	Dir string

	// Files are absolute paths, all under Dir.
	Files []string

	// BytesTransferred is what actually moved, for reporting.
	BytesTransferred int64

	// Delegated marks a result produced by an external manager that has
	// already imported the files into its own library.  Files are then
	// absolute paths outside staging, and the pipeline records them
	// where they are instead of tagging and moving them.
	Delegated bool
}

// DelegateStatus is a delegating manager's answer to "are we there
// yet?".
type DelegateStatus struct {
	State State

	// Progress is 0..1 when the manager reports it, -1 when it does not.
	Progress float64

	// ImportedPaths are files the manager has already placed on disk.
	// A delegate that imports into its own library reports them here so
	// the pipeline can reconcile rather than re-import.
	ImportedPaths []string

	Message string
}
