package subsonic

import (
	"context"
	"errors"
	"net/http"
	"strconv"
	"strings"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/jackc/pgx/v5/pgxpool"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// browseHandlers carries the shared pgxpool used by every browse endpoint.
// Handlers are methods so Mount can register them without package globals.
type browseHandlers struct {
	pool *pgxpool.Pool
}

// Subsonic's "ignoredArticles" is advisory — it tells clients which leading
// words to strip when computing their own index buckets. The server is
// responsible for storing sort_name in the desired form; this attr exists
// for backwards compatibility with clients that regroup client-side.
const ignoredArticles = "The El La Los Las Le Les"

// getMusicFolders always returns a single folder. Minstrel has no multi-library
// concept in M1; clients use the folder id as an opaque cookie so we return 1.
func (b *browseHandlers) getMusicFolders(w http.ResponseWriter, r *http.Request) {
	Write(w, r, MusicFoldersResponse{
		Envelope: NewEnvelope("ok"),
		MusicFolders: MusicFoldersContainer{
			MusicFolders: []MusicFolder{{ID: 1, Name: "Music"}},
		},
	})
}

// getIndexes is the legacy namespace — identical shape to getArtists but with
// a lastModified attr. We report the max(updated_at) across artists so clients
// can short-circuit re-sync when nothing has changed.
func (b *browseHandlers) getIndexes(w http.ResponseWriter, r *http.Request) {
	q := dbq.New(b.pool)
	indexes, lastMod, err := b.buildArtistIndexes(r.Context(), q)
	if err != nil {
		WriteFail(w, r, ErrGeneric, "Failed to load artists")
		return
	}
	Write(w, r, IndexesResponse{
		Envelope: NewEnvelope("ok"),
		Indexes: IndexesContainer{
			LastModified:    lastMod,
			IgnoredArticles: ignoredArticles,
			Indexes:         indexes,
		},
	})
}

// getArtists is the ID3 equivalent of getIndexes. Same bucketing, no
// lastModified. Most modern clients prefer this endpoint.
func (b *browseHandlers) getArtists(w http.ResponseWriter, r *http.Request) {
	q := dbq.New(b.pool)
	indexes, _, err := b.buildArtistIndexes(r.Context(), q)
	if err != nil {
		WriteFail(w, r, ErrGeneric, "Failed to load artists")
		return
	}
	Write(w, r, ArtistsResponse{
		Envelope: NewEnvelope("ok"),
		Artists: ArtistsContainer{
			IgnoredArticles: ignoredArticles,
			Indexes:         indexes,
		},
	})
}

// buildArtistIndexes fetches every artist, buckets them by first letter, and
// returns indexes + the unix-ms timestamp of the most recently updated artist.
func (b *browseHandlers) buildArtistIndexes(ctx context.Context, q *dbq.Queries) ([]Index, int64, error) {
	artists, err := q.ListArtists(ctx)
	if err != nil {
		return nil, 0, err
	}
	buckets := make(map[string][]ArtistRef)
	var order []string
	var lastMod int64
	for _, a := range artists {
		if a.UpdatedAt.Valid {
			ms := a.UpdatedAt.Time.UnixMilli()
			if ms > lastMod {
				lastMod = ms
			}
		}
		albums, err := q.ListAlbumsByArtist(ctx, a.ID)
		if err != nil {
			return nil, 0, err
		}
		ref := artistRef(a, len(albums))
		letter := indexLetter(a.SortName)
		if _, seen := buckets[letter]; !seen {
			order = append(order, letter)
		}
		buckets[letter] = append(buckets[letter], ref)
	}
	indexes := make([]Index, 0, len(order))
	for _, letter := range order {
		indexes = append(indexes, Index{Name: letter, Artists: buckets[letter]})
	}
	return indexes, lastMod, nil
}

// getArtist returns one artist with its nested albums. id is a UUID string.
func (b *browseHandlers) getArtist(w http.ResponseWriter, r *http.Request) {
	idStr := r.URL.Query().Get("id")
	if idStr == "" {
		WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id")
		return
	}
	id, ok := parseUUID(idStr)
	if !ok {
		WriteFail(w, r, ErrDataNotFound, "Artist not found")
		return
	}
	q := dbq.New(b.pool)
	artist, err := q.GetArtistByID(r.Context(), id)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			WriteFail(w, r, ErrDataNotFound, "Artist not found")
			return
		}
		WriteFail(w, r, ErrGeneric, "Failed to load artist")
		return
	}
	albums, err := q.ListAlbumsByArtist(r.Context(), id)
	if err != nil {
		WriteFail(w, r, ErrGeneric, "Failed to load albums")
		return
	}
	refs := make([]AlbumRef, 0, len(albums))
	for _, a := range albums {
		count, _ := q.CountTracksByAlbum(r.Context(), a.ID)
		refs = append(refs, albumRef(a, artist.Name, int(count), 0))
	}
	Write(w, r, ArtistResponse{
		Envelope: NewEnvelope("ok"),
		Artist: ArtistDetail{
			ID:         uuidToID(artist.ID),
			Name:       artist.Name,
			AlbumCount: len(albums),
			Albums:     refs,
		},
	})
}

// getAlbum returns one album with its full track list.
func (b *browseHandlers) getAlbum(w http.ResponseWriter, r *http.Request) {
	idStr := r.URL.Query().Get("id")
	if idStr == "" {
		WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id")
		return
	}
	id, ok := parseUUID(idStr)
	if !ok {
		WriteFail(w, r, ErrDataNotFound, "Album not found")
		return
	}
	q := dbq.New(b.pool)
	album, err := q.GetAlbumByID(r.Context(), id)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			WriteFail(w, r, ErrDataNotFound, "Album not found")
			return
		}
		WriteFail(w, r, ErrGeneric, "Failed to load album")
		return
	}
	artist, err := q.GetArtistByID(r.Context(), album.ArtistID)
	if err != nil {
		WriteFail(w, r, ErrGeneric, "Failed to load album artist")
		return
	}
	tracks, err := q.ListTracksByAlbum(r.Context(), dbq.ListTracksByAlbumParams{AlbumID: id})
	if err != nil {
		WriteFail(w, r, ErrGeneric, "Failed to load tracks")
		return
	}
	songs := make([]SongRef, 0, len(tracks))
	for _, t := range tracks {
		songs = append(songs, songRef(t, album.Title, artist.Name))
	}
	Write(w, r, AlbumResponse{
		Envelope: NewEnvelope("ok"),
		Album:    albumDetail(album, artist.Name, songs),
	})
}

// getSong returns a single track by id. Parent metadata (album/artist name)
// is included so clients can render the song outside an album context.
func (b *browseHandlers) getSong(w http.ResponseWriter, r *http.Request) {
	idStr := r.URL.Query().Get("id")
	if idStr == "" {
		WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id")
		return
	}
	id, ok := parseUUID(idStr)
	if !ok {
		WriteFail(w, r, ErrDataNotFound, "Song not found")
		return
	}
	q := dbq.New(b.pool)
	track, err := q.GetTrackByID(r.Context(), id)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			WriteFail(w, r, ErrDataNotFound, "Song not found")
			return
		}
		WriteFail(w, r, ErrGeneric, "Failed to load song")
		return
	}
	album, err := q.GetAlbumByID(r.Context(), track.AlbumID)
	if err != nil {
		WriteFail(w, r, ErrGeneric, "Failed to load song album")
		return
	}
	artist, err := q.GetArtistByID(r.Context(), track.ArtistID)
	if err != nil {
		WriteFail(w, r, ErrGeneric, "Failed to load song artist")
		return
	}
	Write(w, r, SongResponse{
		Envelope: NewEnvelope("ok"),
		Song:     songRef(track, album.Title, artist.Name),
	})
}

// getAlbumList2 returns a paged album list filtered by type. Supported types
// match the Subsonic spec § getAlbumList2. "recent"/"frequent" require play
// history (M2) and currently return an empty list rather than an error so
// clients that poll them don't break.
func (b *browseHandlers) getAlbumList2(w http.ResponseWriter, r *http.Request) {
	params := r.URL.Query()
	listType := params.Get("type")
	if listType == "" {
		WriteFail(w, r, ErrMissingParameter, "Missing required parameter: type")
		return
	}
	size := clampInt(atoiDefault(params.Get("size"), 10), 1, 500)
	offset := atoiDefault(params.Get("offset"), 0)
	if offset < 0 {
		offset = 0
	}

	q := dbq.New(b.pool)
	var albums []dbq.Album
	var err error

	switch listType {
	case "newest":
		albums, err = q.ListAlbumsNewest(r.Context(), dbq.ListAlbumsNewestParams{
			Limit: int32(size), Offset: int32(offset),
		})
	case "alphabeticalByName":
		albums, err = q.ListAlbumsAlphaByName(r.Context(), dbq.ListAlbumsAlphaByNameParams{
			Limit: int32(size), Offset: int32(offset),
		})
	case "alphabeticalByArtist":
		rows, rerr := q.ListAlbumsAlphaByArtist(r.Context(), dbq.ListAlbumsAlphaByArtistParams{
			Limit: int32(size), Offset: int32(offset),
		})
		if rerr != nil {
			err = rerr
			break
		}
		albums = make([]dbq.Album, len(rows))
		for i, row := range rows {
			albums[i] = row.Album
		}
	case "random":
		albums, err = q.ListAlbumsRandom(r.Context(), int32(size))
	case "byGenre":
		genre := params.Get("genre")
		if genre == "" {
			WriteFail(w, r, ErrMissingParameter, "Missing required parameter: genre")
			return
		}
		// Genre is a plain string as of #367 — the query now splits
		// tracks.genre on [;,] instead of comparing the whole column, so a
		// client asking for "Rock" also reaches tracks tagged "Rock;Pop".
		// Previously those were unreachable from either of their genres.
		albums, err = q.ListAlbumsByGenre(r.Context(), dbq.ListAlbumsByGenreParams{
			Genre: genre, Lim: int32(size), Off: int32(offset),
		})
	case "recent", "frequent":
		// Play history lands in M2; return empty to keep clients happy.
		albums = nil
	default:
		WriteFail(w, r, ErrGeneric, "Invalid list type: "+listType)
		return
	}
	if err != nil {
		WriteFail(w, r, ErrGeneric, "Failed to list albums")
		return
	}

	refs, err := b.albumRefs(r.Context(), q, albums)
	if err != nil {
		WriteFail(w, r, ErrGeneric, "Failed to resolve albums")
		return
	}
	Write(w, r, AlbumList2Response{
		Envelope:   NewEnvelope("ok"),
		AlbumList2: AlbumList2Container{Albums: refs},
	})
}

// search3 runs three independent searches — artist name, album title, song
// title — each bounded by its own count/offset. The Subsonic spec lets the
// caller page each facet separately, which is why the params triplicate.
func (b *browseHandlers) search3(w http.ResponseWriter, r *http.Request) {
	params := r.URL.Query()
	query := params.Get("query")
	if query == "" {
		WriteFail(w, r, ErrMissingParameter, "Missing required parameter: query")
		return
	}
	artistCount := clampInt(atoiDefault(params.Get("artistCount"), 20), 0, 500)
	artistOffset := atoiDefault(params.Get("artistOffset"), 0)
	albumCount := clampInt(atoiDefault(params.Get("albumCount"), 20), 0, 500)
	albumOffset := atoiDefault(params.Get("albumOffset"), 0)
	songCount := clampInt(atoiDefault(params.Get("songCount"), 20), 0, 500)
	songOffset := atoiDefault(params.Get("songOffset"), 0)

	q := dbq.New(b.pool)
	// Subsonic clients send "" as a wildcard in some implementations; we pass
	// the literal query through sqlc's ILIKE param so it matches substrings.
	needle := &query

	// JSON marshaling of nil slices produces `null`, which some clients reject.
	// Initialize each facet to an empty slice so absent matches render as `[]`.
	result := SearchResult3Container{
		Artists: []ArtistRef{},
		Albums:  []AlbumRef{},
		Songs:   []SongRef{},
	}

	if artistCount > 0 {
		artists, err := q.SearchArtists(r.Context(), dbq.SearchArtistsParams{
			Column1: needle, Limit: int32(artistCount), Offset: int32(artistOffset),
		})
		if err != nil {
			WriteFail(w, r, ErrGeneric, "Artist search failed")
			return
		}
		for _, a := range artists {
			albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID)
			if aerr != nil {
				WriteFail(w, r, ErrGeneric, "Artist search failed")
				return
			}
			result.Artists = append(result.Artists, artistRef(a, len(albums)))
		}
	}

	if albumCount > 0 {
		albums, err := q.SearchAlbums(r.Context(), dbq.SearchAlbumsParams{
			Column1: needle, Limit: int32(albumCount), Offset: int32(albumOffset),
		})
		if err != nil {
			WriteFail(w, r, ErrGeneric, "Album search failed")
			return
		}
		refs, rerr := b.albumRefs(r.Context(), q, albums)
		if rerr != nil {
			WriteFail(w, r, ErrGeneric, "Album search failed")
			return
		}
		result.Albums = refs
	}

	if songCount > 0 {
		tracks, err := q.SearchTracks(r.Context(), dbq.SearchTracksParams{
			Column1: query, Limit: int32(songCount), Offset: int32(songOffset),
		}) // UserID left zero (NULL) — Subsonic has no per-user quarantine context
		if err != nil {
			WriteFail(w, r, ErrGeneric, "Song search failed")
			return
		}
		for _, t := range tracks {
			album, aerr := q.GetAlbumByID(r.Context(), t.AlbumID)
			if aerr != nil {
				WriteFail(w, r, ErrGeneric, "Song search failed")
				return
			}
			artist, aerr := q.GetArtistByID(r.Context(), t.ArtistID)
			if aerr != nil {
				WriteFail(w, r, ErrGeneric, "Song search failed")
				return
			}
			result.Songs = append(result.Songs, songRef(t, album.Title, artist.Name))
		}
	}

	Write(w, r, SearchResult3Response{
		Envelope:      NewEnvelope("ok"),
		SearchResult3: result,
	})
}

// albumRefs converts a list of dbq.Album rows to AlbumRef, resolving artist
// name and track count per album. Uses resolveArtistNames for the artist side
// (one query per distinct artist) and CountTracksByAlbum per album. For small
// page sizes (≤ 500) this is well within budget.
func (b *browseHandlers) albumRefs(ctx context.Context, q *dbq.Queries, albums []dbq.Album) ([]AlbumRef, error) {
	// Always return a non-nil slice — JSON clients expect [] on empty results,
	// not null. Same rationale applies to search3 facet slices.
	if len(albums) == 0 {
		return []AlbumRef{}, nil
	}
	ids := make([]pgtype.UUID, 0, len(albums))
	for _, a := range albums {
		ids = append(ids, a.ArtistID)
	}
	names, err := resolveArtistNames(ctx, q, ids)
	if err != nil {
		return nil, err
	}
	refs := make([]AlbumRef, 0, len(albums))
	for _, a := range albums {
		count, _ := q.CountTracksByAlbum(ctx, a.ID)
		refs = append(refs, albumRef(a, names[uuidToID(a.ArtistID)], int(count), 0))
	}
	return refs, nil
}

func atoiDefault(s string, def int) int {
	if s == "" {
		return def
	}
	n, err := strconv.Atoi(strings.TrimSpace(s))
	if err != nil {
		return def
	}
	return n
}

func clampInt(v, lo, hi int) int {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}
