package lidarr

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"
)

// Client wraps Lidarr's v1 HTTP API. BaseURL is the Lidarr instance
// (e.g. http://lidarr.lan:8686), APIKey comes from Lidarr's settings.
type Client struct {
	BaseURL string
	APIKey  string
	HTTP    *http.Client
}

// NewClient builds a Client with sensible HTTP defaults (30s timeout,
// matching the pattern in internal/scrobble/listenbrainz). Call sites
// that need a custom client can construct the Client struct directly.
func NewClient(baseURL, apiKey string) *Client {
	return &Client{
		BaseURL: baseURL,
		APIKey:  apiKey,
		HTTP:    &http.Client{Timeout: 30 * time.Second},
	}
}

// get executes an authenticated GET request and maps HTTP status codes to
// typed errors. The caller is responsible for closing the returned body.
func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Response, error) {
	u, err := url.Parse(c.BaseURL)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
	}
	u.Path = strings.TrimRight(u.Path, "/") + path
	if q != nil {
		u.RawQuery = q.Encode()
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("X-Api-Key", c.APIKey)
	resp, err := c.HTTP.Do(req)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
	}
	if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
		_ = resp.Body.Close()
		return nil, ErrAuthFailed
	}
	if resp.StatusCode >= 500 {
		body := readBodySnippet(resp.Body)
		_ = resp.Body.Close()
		return nil, fmt.Errorf("%w: %d: %s", ErrServerError, resp.StatusCode, body)
	}
	if resp.StatusCode >= 400 {
		body := readBodySnippet(resp.Body)
		_ = resp.Body.Close()
		return nil, fmt.Errorf("%w: %d: %s", ErrLookupFailed, resp.StatusCode, body)
	}
	return resp, nil
}

// readBodySnippet reads up to 512 bytes from an error response body so the
// wrapped error message can include Lidarr's actual complaint (e.g.
// `{"errorMessage":"Artist already exists in your library"}`). Without this,
// callers see only the status-code bucket and can't diagnose without
// cross-referencing Lidarr's own logs.
func readBodySnippet(r io.Reader) string {
	buf, _ := io.ReadAll(io.LimitReader(r, 512))
	return strings.TrimSpace(string(buf))
}

// post is the shared POST helper. body must be marshaled JSON. It maps HTTP
// status codes to typed errors; the caller is responsible for closing the
// returned body.
func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Response, error) {
	return c.bodyRequest(ctx, http.MethodPost, path, body)
}

// put is the shared PUT helper. Body must be marshaled JSON. Mirrors post() —
// same status-code mapping, same caller-closes-body contract.
func (c *Client) put(ctx context.Context, path string, body []byte) (*http.Response, error) {
	return c.bodyRequest(ctx, http.MethodPut, path, body)
}

// bodyRequest is the shared core for POST and PUT. Lidarr's monitor toggle
// is PUT-shaped, so factoring it out keeps the post/put helpers from
// duplicating the status-code mapping.
func (c *Client) bodyRequest(ctx context.Context, method, path string, body []byte) (*http.Response, error) {
	u, err := url.Parse(c.BaseURL)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
	}
	u.Path = strings.TrimRight(u.Path, "/") + path
	req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("X-Api-Key", c.APIKey)
	req.Header.Set("Content-Type", "application/json")
	resp, err := c.HTTP.Do(req)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
	}
	if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
		_ = resp.Body.Close()
		return nil, ErrAuthFailed
	}
	if resp.StatusCode >= 500 {
		body := readBodySnippet(resp.Body)
		_ = resp.Body.Close()
		return nil, fmt.Errorf("%w: %d: %s", ErrServerError, resp.StatusCode, body)
	}
	if resp.StatusCode >= 400 {
		body := readBodySnippet(resp.Body)
		_ = resp.Body.Close()
		return nil, fmt.Errorf("%w: %d: %s", ErrLookupFailed, resp.StatusCode, body)
	}
	return resp, nil
}

// LookupArtist hits GET /api/v1/artist/lookup?term=. Returns normalized
// LookupResults; Secondary is "{first genre} · {albumCount} albums" with
// missing parts skipped.
func (c *Client) LookupArtist(ctx context.Context, term string) ([]LookupResult, error) {
	resp, err := c.get(ctx, "/api/v1/artist/lookup", url.Values{"term": {term}})
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	var raw []struct {
		ForeignArtistID string        `json:"foreignArtistId"`
		ArtistName      string        `json:"artistName"`
		Genres          []string      `json:"genres"`
		AlbumCount      int           `json:"albumCount"`
		Images          []lidarrImage `json:"images"`
	}
	if err := json.Unmarshal(body, &raw); err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	out := make([]LookupResult, 0, len(raw))
	for _, r := range raw {
		secondary := ""
		if len(r.Genres) > 0 {
			secondary = r.Genres[0]
		}
		if r.AlbumCount > 0 {
			if secondary != "" {
				secondary += " · "
			}
			secondary += strconv.Itoa(r.AlbumCount) + " albums"
		}
		out = append(out, LookupResult{
			MBID:      r.ForeignArtistID,
			Name:      r.ArtistName,
			Secondary: secondary,
			ImageURL:  pickPosterImage(r.Images),
		})
	}
	return out, nil
}

// LookupAlbum hits GET /api/v1/album/lookup?term=. Returns normalized
// LookupResults; Secondary is "{artistName} · {year} · {trackCount} tracks".
func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult, error) {
	resp, err := c.get(ctx, "/api/v1/album/lookup", url.Values{"term": {term}})
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	var raw []struct {
		ForeignAlbumID  string        `json:"foreignAlbumId"`
		ForeignArtistID string        `json:"foreignArtistId"`
		Title           string        `json:"title"`
		ArtistName      string        `json:"artistName"`
		ReleaseDate     string        `json:"releaseDate"`
		TrackCount      int           `json:"trackCount"`
		Images          []lidarrImage `json:"images"`
	}
	if err := json.Unmarshal(body, &raw); err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	out := make([]LookupResult, 0, len(raw))
	for _, r := range raw {
		year := ""
		if len(r.ReleaseDate) >= 4 {
			year = r.ReleaseDate[:4]
		}
		secondary := ""
		if r.ArtistName != "" {
			secondary = r.ArtistName
		}
		if year != "" {
			if secondary != "" {
				secondary += " · "
			}
			secondary += year
		}
		if r.TrackCount > 0 {
			if secondary != "" {
				secondary += " · "
			}
			secondary += strconv.Itoa(r.TrackCount) + " tracks"
		}
		out = append(out, LookupResult{
			MBID:       r.ForeignAlbumID,
			ArtistMBID: r.ForeignArtistID,
			Name:       r.Title,
			Secondary:  secondary,
			ImageURL:   pickPosterImage(r.Images),
		})
	}
	return out, nil
}

// LookupTrack hits GET /api/v1/track/lookup?term=. Lidarr's track lookup is
// per-album under the hood. Secondary is "{albumTitle} · {artistName}".
func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult, error) {
	resp, err := c.get(ctx, "/api/v1/track/lookup", url.Values{"term": {term}})
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	var raw []struct {
		ForeignTrackID  string `json:"foreignTrackId"`
		ForeignAlbumID  string `json:"foreignAlbumId"`
		ForeignArtistID string `json:"foreignArtistId"`
		Title           string `json:"title"`
		AlbumTitle      string `json:"albumTitle"`
		ArtistName      string `json:"artistName"`
	}
	if err := json.Unmarshal(body, &raw); err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	out := make([]LookupResult, 0, len(raw))
	for _, r := range raw {
		secondary := r.AlbumTitle
		if r.ArtistName != "" {
			if secondary != "" {
				secondary += " · "
			}
			secondary += r.ArtistName
		}
		out = append(out, LookupResult{
			MBID:       r.ForeignTrackID,
			ArtistMBID: r.ForeignArtistID,
			AlbumMBID:  r.ForeignAlbumID,
			Name:       r.Title,
			Secondary:  secondary,
		})
	}
	return out, nil
}

// AddArtist posts to POST /api/v1/artist. MonitorAll=true sends monitor="all";
// false sends "future". Returns nil on 2xx; typed error otherwise.
//
// All four of artistName, foreignArtistId, qualityProfileId, and
// metadataProfileId are required by Lidarr. Omitting any one produces a
// 400 with field-level validation messages (e.g. "'Metadata Profile Id'
// must be greater than '0'").
func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error {
	monitor := "future"
	if p.MonitorAll {
		monitor = "all"
	}
	body, err := json.Marshal(map[string]any{
		"foreignArtistId":   p.ForeignArtistID,
		"artistName":        p.ArtistName,
		"qualityProfileId":  p.QualityProfileID,
		"metadataProfileId": p.MetadataProfileID,
		"rootFolderPath":    p.RootFolderPath,
		"monitored":         true,
		"monitor":           monitor,
		"addOptions":        map[string]any{"searchForMissingAlbums": true},
	})
	if err != nil {
		return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
	}
	resp, err := c.post(ctx, "/api/v1/artist", body)
	if err != nil {
		if isAlreadyExists(err) {
			return ErrAlreadyExists
		}
		return err
	}
	_ = resp.Body.Close()
	return nil
}

// isAlreadyExists detects Lidarr's 400 "already in your library" rejection
// from an add endpoint. The post() helper buckets it as ErrLookupFailed
// but embeds Lidarr's errorMessage body snippet; matching the phrase lets
// the idempotent retry path treat a re-add as success instead of error.
func isAlreadyExists(err error) bool {
	if !errors.Is(err, ErrLookupFailed) {
		return false
	}
	m := strings.ToLower(err.Error())
	return strings.Contains(m, "already exists") ||
		strings.Contains(m, "already been added") ||
		strings.Contains(m, "already in your library")
}

// AddAlbum posts to POST /api/v1/album. Returns nil on 2xx; typed error
// otherwise.
func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
	body, err := json.Marshal(map[string]any{
		"foreignAlbumId":    p.ForeignAlbumID,
		"foreignArtistId":   p.ForeignArtistID,
		"artistName":        p.ArtistName,
		"qualityProfileId":  p.QualityProfileID,
		"metadataProfileId": p.MetadataProfileID,
		"rootFolderPath":    p.RootFolderPath,
		"monitored":         true,
		"addOptions":        map[string]any{"searchForNewAlbum": true},
	})
	if err != nil {
		return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
	}
	resp, err := c.post(ctx, "/api/v1/album", body)
	if err != nil {
		if isAlreadyExists(err) {
			return ErrAlreadyExists
		}
		return err
	}
	_ = resp.Body.Close()
	return nil
}

// ListMetadataProfiles hits GET /api/v1/metadataprofile and returns the
// list of metadata profiles configured in Lidarr. The Approve flow uses
// the first profile as a default when the operator hasn't pinned one.
func (c *Client) ListMetadataProfiles(ctx context.Context) ([]MetadataProfile, error) {
	resp, err := c.get(ctx, "/api/v1/metadataprofile", nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	var raw []struct {
		ID   int    `json:"id"`
		Name string `json:"name"`
	}
	if err := json.Unmarshal(body, &raw); err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	out := make([]MetadataProfile, len(raw))
	for i, r := range raw {
		out[i] = MetadataProfile{ID: r.ID, Name: r.Name}
	}
	return out, nil
}

// ListQualityProfiles hits GET /api/v1/qualityprofile and returns the full
// list of quality profiles configured in Lidarr.
func (c *Client) ListQualityProfiles(ctx context.Context) ([]QualityProfile, error) {
	resp, err := c.get(ctx, "/api/v1/qualityprofile", nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	var raw []struct {
		ID   int    `json:"id"`
		Name string `json:"name"`
	}
	if err := json.Unmarshal(body, &raw); err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	out := make([]QualityProfile, len(raw))
	for i, r := range raw {
		out[i] = QualityProfile{ID: r.ID, Name: r.Name}
	}
	return out, nil
}

// ListRootFolders hits GET /api/v1/rootfolder and returns the list of root
// folders configured in Lidarr.
func (c *Client) ListRootFolders(ctx context.Context) ([]RootFolder, error) {
	resp, err := c.get(ctx, "/api/v1/rootfolder", nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	var raw []struct {
		Path       string `json:"path"`
		Accessible bool   `json:"accessible"`
		FreeSpace  int64  `json:"freeSpace"`
	}
	if err := json.Unmarshal(body, &raw); err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	out := make([]RootFolder, len(raw))
	for i, r := range raw {
		out[i] = RootFolder{Path: r.Path, Accessible: r.Accessible, FreeSpace: r.FreeSpace}
	}
	return out, nil
}

// Ping hits GET /api/v1/system/status to verify connectivity and returns
// the Lidarr version string.
func (c *Client) Ping(ctx context.Context) (PingResult, error) {
	resp, err := c.get(ctx, "/api/v1/system/status", nil)
	if err != nil {
		return PingResult{}, err
	}
	defer func() { _ = resp.Body.Close() }()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	var raw struct {
		Version string `json:"version"`
	}
	if err := json.Unmarshal(body, &raw); err != nil {
		return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
	}
	return PingResult{Version: raw.Version}, nil
}

// pickPosterImage returns the remote URL (preferred) or local URL for the
// first image with coverType=="poster". Returns "" when none match.
func pickPosterImage(imgs []lidarrImage) string {
	for _, img := range imgs {
		if img.CoverType == "poster" {
			if img.RemoteURL != "" {
				return img.RemoteURL
			}
			return img.URL
		}
	}
	return ""
}
