package qobuz

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

const baseURL = "https://www.qobuz.com/api.json/0.2"

const defaultUA = "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717"
const defaultAppVersion = "9.7.0.3"
const defaultDevicePlatform = "android"
const defaultDeviceModel = "Nexus 6P"
const defaultDeviceOSVersion = "9"

type Client struct {
	httpClient *http.Client
	appID      string
	appSecret  string
	token      string
}

type Track struct {
	ID       int64
	Title    string
	Version  string
	Duration int
	ISRC     string
	Artist   string
	Album    string
}

var ErrDuplicateTracks = errors.New("qobuz duplicate tracks")

func NewClient(appID, appSecret string) *Client {
	return &Client{
		httpClient: &http.Client{Timeout: 30 * time.Second},
		appID:      appID,
		appSecret:  appSecret,
	}
}

func (c *Client) SetToken(token string) {
	c.token = strings.TrimSpace(token)
}

func (c *Client) Token() string {
	return c.token
}

func (c *Client) Login(ctx context.Context, username, password string) error {
	type oauthResponse struct {
		OAuth2 struct {
			AccessToken string `json:"access_token"`
		} `json:"oauth2"`
		AccessToken string `json:"access_token"`
	}

	rawPassword := strings.TrimSpace(password)
	md5Password := md5Hex(rawPassword)

	attempts := []struct {
		Method   string
		Password string
	}{
		{Method: http.MethodGet, Password: md5Password},
		{Method: http.MethodGet, Password: rawPassword},
		{Method: http.MethodPost, Password: md5Password},
		{Method: http.MethodPost, Password: rawPassword},
	}

	var lastErr error
	for _, a := range attempts {
		params := url.Values{}
		params.Set("username", username)
		params.Set("password", a.Password)

		var out oauthResponse
		var err error
		if a.Method == http.MethodPost {
			err = c.postFormSigned(ctx, "/oauth2/login", params, &out)
		} else {
			err = c.getSigned(ctx, "/oauth2/login", params, &out)
		}
		if err != nil {
			lastErr = err
			continue
		}

		token := strings.TrimSpace(out.OAuth2.AccessToken)
		if token == "" {
			token = strings.TrimSpace(out.AccessToken)
		}
		if token == "" {
			lastErr = fmt.Errorf("qobuz login response missing access_token")
			continue
		}
		c.token = token
		return nil
	}

	if lastErr == nil {
		lastErr = fmt.Errorf("qobuz login failed")
	}
	return lastErr
}

func (c *Client) VerifyAuth(ctx context.Context) error {
	var out map[string]any
	if err := c.getUnsigned(ctx, "/user/get", url.Values{}, &out); err == nil {
		return nil
	}
	if err := c.getSigned(ctx, "/user/get", url.Values{}, &out); err != nil {
		return fmt.Errorf("verify auth failed for both unsigned and signed user/get: %w", err)
	}
	return nil
}

func (c *Client) SearchTracks(ctx context.Context, query string, limit int) ([]Track, error) {
	if limit <= 0 {
		limit = 8
	}
	params := url.Values{}
	params.Set("query", query)
	params.Set("limit", strconv.Itoa(limit))
	params.Set("offset", "0")

	type response struct {
		Tracks struct {
			Items []struct {
				ID        int64  `json:"id"`
				Title     string `json:"title"`
				Version   string `json:"version"`
				Duration  int    `json:"duration"`
				ISRC      string `json:"isrc"`
				Performer struct {
					Name string `json:"name"`
				} `json:"performer"`
				Album struct {
					Title string `json:"title"`
				} `json:"album"`
			} `json:"items"`
		} `json:"tracks"`
	}

	var out response
	if err := c.getSigned(ctx, "/track/search", params, &out); err != nil {
		return nil, err
	}

	res := make([]Track, 0, len(out.Tracks.Items))
	for _, it := range out.Tracks.Items {
		res = append(res, Track{
			ID:       it.ID,
			Title:    it.Title,
			Version:  it.Version,
			Duration: it.Duration,
			ISRC:     strings.ToUpper(strings.TrimSpace(it.ISRC)),
			Artist:   it.Performer.Name,
			Album:    it.Album.Title,
		})
	}
	return res, nil
}

func (c *Client) CreatePlaylist(ctx context.Context, name, description string, isPublic bool) (int64, error) {
	form := url.Values{}
	form.Set("name", name)
	form.Set("description", description)
	if isPublic {
		form.Set("is_public", "true")
	} else {
		form.Set("is_public", "false")
	}
	form.Set("is_collaborative", "false")

	var out struct {
		ID int64 `json:"id"`
	}
	if err := c.postFormSigned(ctx, "/playlist/create", form, &out); err != nil {
		return 0, err
	}
	if out.ID == 0 {
		return 0, fmt.Errorf("playlist/create returned empty playlist id")
	}
	return out.ID, nil
}

func (c *Client) AddTracksToPlaylist(ctx context.Context, playlistID int64, trackIDs []int64) error {
	if len(trackIDs) == 0 {
		return nil
	}
	hadDuplicate := false
	chunks := chunk(trackIDs, 100)
	for _, ch := range chunks {
		ids := make([]string, 0, len(ch))
		for _, id := range ch {
			ids = append(ids, strconv.FormatInt(id, 10))
		}
		form := url.Values{}
		form.Set("playlist_id", strconv.FormatInt(playlistID, 10))
		form.Set("track_ids", strings.Join(ids, ","))
		form.Set("no_duplicate", "true")

		var out map[string]any
		if err := c.postFormSigned(ctx, "/playlist/addTracks", form, &out); err != nil {
			if isDuplicateConflictErr(err) {
				hadDuplicate = true
				continue
			}
			return err
		}
	}
	if hadDuplicate {
		return ErrDuplicateTracks
	}
	return nil
}

func (c *Client) DeletePlaylist(ctx context.Context, playlistID int64) error {
	params := url.Values{}
	params.Set("playlist_id", strconv.FormatInt(playlistID, 10))
	var out map[string]any
	if err := c.getSigned(ctx, "/playlist/delete", params, &out); err != nil {
		return err
	}
	return nil
}

func (c *Client) getSigned(ctx context.Context, path string, params url.Values, out any) error {
	query := cloneValues(params)
	ts, sig := signGet(path, c.appSecret, query)
	query.Set("app_id", c.appID)
	query.Set("request_ts", ts)
	query.Set("request_sig", sig)

	return c.doJSON(ctx, http.MethodGet, path, query, url.Values{}, out)
}

func (c *Client) getUnsigned(ctx context.Context, path string, params url.Values, out any) error {
	query := cloneValues(params)
	query.Set("app_id", c.appID)
	return c.doJSON(ctx, http.MethodGet, path, query, url.Values{}, out)
}

func (c *Client) postFormSigned(ctx context.Context, path string, form url.Values, out any) error {
	for _, includeValues := range []bool{false, true} {
		query := url.Values{}
		ts, sig := signPost(path, c.appSecret, form, includeValues)
		query.Set("app_id", c.appID)
		query.Set("request_ts", ts)
		query.Set("request_sig", sig)

		err := c.doJSON(ctx, http.MethodPost, path, query, form, out)
		if err == nil {
			return nil
		}
		if !isSigError(err) {
			return err
		}
	}
	return fmt.Errorf("qobuz request signature rejected for %s", path)
}

func (c *Client) doJSON(ctx context.Context, method, path string, query, form url.Values, out any) error {
	u := baseURL + path
	if len(query) > 0 {
		u += "?" + query.Encode()
	}
	bodyEncoded := ""
	if method == http.MethodPost {
		bodyEncoded = form.Encode()
	}

	var lastErr error
	for attempt := 1; attempt <= 4; attempt++ {
		var body io.Reader
		if method == http.MethodPost {
			body = strings.NewReader(bodyEncoded)
		}

		req, err := http.NewRequestWithContext(ctx, method, u, body)
		if err != nil {
			return err
		}
		req.Header.Set("User-Agent", defaultUA)
		req.Header.Set("Accept", "application/json")
		req.Header.Set("X-App-Id", c.appID)
		req.Header.Set("X-App-Version", defaultAppVersion)
		req.Header.Set("X-Device-Platform", defaultDevicePlatform)
		req.Header.Set("X-Device-Model", defaultDeviceModel)
		req.Header.Set("X-Device-Os-Version", defaultDeviceOSVersion)
		if c.token != "" {
			req.Header.Set("Authorization", "Bearer "+c.token)
			req.Header.Set("X-User-Auth-Token", c.token)
		}
		if method == http.MethodPost {
			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		}

		resp, err := c.httpClient.Do(req)
		if err != nil {
			lastErr = err
			time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
			continue
		}

		if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
			resp.Body.Close()
			time.Sleep(time.Duration(attempt) * time.Second)
			continue
		}

		if resp.StatusCode >= 300 {
			b, _ := io.ReadAll(resp.Body)
			resp.Body.Close()
			return fmt.Errorf("qobuz api error (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
		}

		defer resp.Body.Close()
		if out == nil {
			_, _ = io.Copy(io.Discard, resp.Body)
			return nil
		}
		if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
			return err
		}
		return nil
	}

	if lastErr == nil {
		lastErr = fmt.Errorf("qobuz request failed after retries")
	}
	return lastErr
}

func chunk(ids []int64, size int) [][]int64 {
	if size <= 0 {
		size = 100
	}
	out := make([][]int64, 0, (len(ids)+size-1)/size)
	for i := 0; i < len(ids); i += size {
		j := i + size
		if j > len(ids) {
			j = len(ids)
		}
		out = append(out, ids[i:j])
	}
	return out
}

func cloneValues(v url.Values) url.Values {
	res := url.Values{}
	for k, values := range v {
		cp := make([]string, len(values))
		copy(cp, values)
		res[k] = cp
	}
	return res
}

func isSigError(err error) bool {
	if err == nil {
		return false
	}
	msg := strings.ToLower(err.Error())
	return strings.Contains(msg, "signature") || strings.Contains(msg, "request_sig")
}

func isDuplicateConflictErr(err error) bool {
	if err == nil {
		return false
	}
	msg := strings.ToLower(err.Error())
	if !strings.Contains(msg, "duplicate track") {
		return false
	}
	return strings.Contains(msg, "(409)") || strings.Contains(msg, `"code":409`) || strings.Contains(msg, "code: 409")
}

func md5Hex(s string) string {
	h := md5.Sum([]byte(s))
	return hex.EncodeToString(h[:])
}
