package coverart

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"math/rand"
	"net/http"
	"sync"
	"time"
)

// httpClientOptions configures a httpClient. Zero values get sensible
// defaults: 3 retries, 250ms base backoff, 1MB JSON body limit, 5MB
// image body limit, default http.Client with 30s timeout.
type httpClientOptions struct {
	HTTPClient *http.Client
	// Name is used as the prefix in wrapped error messages
	// (e.g. "coverart lastfm: build request: ..."). Required.
	Name string
	// MinInterval throttles requests; 0 disables rate limiting.
	MinInterval time.Duration
	// MaxRetries is the number of retry attempts on 429 / 5xx.
	MaxRetries int
	// BaseBackoff is the base unit for exponential backoff; the
	// helper sleeps 2^attempt × BaseBackoff ± 20% jitter.
	BaseBackoff time.Duration
	// TreatAuthAsTransient maps 401/403 responses to ErrTransient
	// (vs. passing through as a generic non-2xx error). Providers
	// with API keys (lastfm, theaudiodb) want this; key-less
	// providers (deezer) leave it false.
	TreatAuthAsTransient bool
	// JSONBodyLimit and ImageBodyLimit cap the response body read
	// to protect against runaway upstreams.
	JSONBodyLimit  int64
	ImageBodyLimit int64
}

// httpClient performs rate-limited, retrying GETs against an HTTP
// JSON or image upstream. Shared by the cover-art providers — each
// constructs its own client with provider-specific intervals and auth
// semantics, then calls getJSON/getImage with fully-formed URLs.
type httpClient struct {
	opts httpClientOptions

	mu       sync.Mutex
	lastCall time.Time
}

func newHTTPClient(opts httpClientOptions) *httpClient {
	if opts.HTTPClient == nil {
		opts.HTTPClient = &http.Client{Timeout: 30 * time.Second}
	}
	if opts.MaxRetries == 0 {
		opts.MaxRetries = 3
	}
	if opts.BaseBackoff == 0 {
		opts.BaseBackoff = 250 * time.Millisecond
	}
	if opts.JSONBodyLimit == 0 {
		opts.JSONBodyLimit = 1 * 1024 * 1024
	}
	if opts.ImageBodyLimit == 0 {
		opts.ImageBodyLimit = 5 * 1024 * 1024
	}
	return &httpClient{opts: opts}
}

// getJSON sends GET fullURL, decodes the 200 body into out (capped at
// JSONBodyLimit). Retries 429/5xx with exponential backoff up to
// MaxRetries. 404 → ErrNotFound. Non-2xx → wrapped ErrTransient.
func (c *httpClient) getJSON(ctx context.Context, fullURL string, out any) error {
	for attempt := 0; attempt <= c.opts.MaxRetries; attempt++ {
		if err := c.rateLimitWait(ctx); err != nil {
			return err
		}
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
		if err != nil {
			return fmt.Errorf("coverart %s: build request: %w", c.opts.Name, err)
		}
		req.Header.Set("Accept", "application/json")

		resp, err := c.opts.HTTPClient.Do(req)
		if err != nil {
			if attempt < c.opts.MaxRetries {
				if waitErr := c.backoff(ctx, attempt); waitErr != nil {
					return waitErr
				}
				continue
			}
			return fmt.Errorf("%w: %v", ErrTransient, err)
		}

		switch {
		case resp.StatusCode == http.StatusNotFound:
			_ = resp.Body.Close()
			return ErrNotFound
		case c.opts.TreatAuthAsTransient &&
			(resp.StatusCode == http.StatusUnauthorized ||
				resp.StatusCode == http.StatusForbidden):
			_ = resp.Body.Close()
			return fmt.Errorf("%w: status %d (auth)", ErrTransient, resp.StatusCode)
		case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500:
			_ = resp.Body.Close()
			if attempt < c.opts.MaxRetries {
				if waitErr := c.backoff(ctx, attempt); waitErr != nil {
					return waitErr
				}
				continue
			}
			return fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
		case resp.StatusCode != http.StatusOK:
			_ = resp.Body.Close()
			return fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
		}

		body, err := io.ReadAll(io.LimitReader(resp.Body, c.opts.JSONBodyLimit))
		_ = resp.Body.Close()
		if err != nil {
			return fmt.Errorf("%w: read body: %v", ErrTransient, err)
		}
		if err := json.Unmarshal(body, out); err != nil {
			return fmt.Errorf("%w: decode body: %v", ErrTransient, err)
		}
		return nil
	}
	return fmt.Errorf("%w: exhausted retries", ErrTransient)
}

// getImage sends GET imgURL, returns the body bytes (capped at
// ImageBodyLimit). 404 → ErrNotFound; non-2xx / network → ErrTransient.
// No retry — image endpoints are typically CDN-backed and 429s are rare.
func (c *httpClient) getImage(ctx context.Context, imgURL string) ([]byte, error) {
	if err := c.rateLimitWait(ctx); err != nil {
		return nil, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, imgURL, nil)
	if err != nil {
		return nil, fmt.Errorf("coverart %s: build request: %w", c.opts.Name, err)
	}
	req.Header.Set("Accept", "image/*")
	resp, err := c.opts.HTTPClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrTransient, err)
	}
	defer func() { _ = resp.Body.Close() }()

	switch {
	case resp.StatusCode == http.StatusNotFound:
		return nil, ErrNotFound
	case resp.StatusCode != http.StatusOK:
		return nil, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
	}

	body, err := io.ReadAll(io.LimitReader(resp.Body, c.opts.ImageBodyLimit))
	if err != nil {
		return nil, fmt.Errorf("%w: read body: %v", ErrTransient, err)
	}
	return body, nil
}

// rateLimitWait blocks until at least MinInterval has elapsed since the
// last call, then claims the slot. Disabled when MinInterval is 0.
func (c *httpClient) rateLimitWait(ctx context.Context) error {
	if c.opts.MinInterval <= 0 {
		return nil
	}
	c.mu.Lock()
	wait := time.Until(c.lastCall.Add(c.opts.MinInterval))
	if wait > 0 {
		timer := time.NewTimer(wait)
		c.mu.Unlock()
		select {
		case <-ctx.Done():
			timer.Stop()
			return ctx.Err()
		case <-timer.C:
		}
		c.mu.Lock()
	}
	c.lastCall = time.Now()
	c.mu.Unlock()
	return nil
}

// backoff sleeps for 2^attempt × BaseBackoff ± 20% jitter, or until ctx is done.
func (c *httpClient) backoff(ctx context.Context, attempt int) error {
	base := time.Duration(1<<attempt) * c.opts.BaseBackoff
	jitterRange := int64(base) / 5
	if jitterRange <= 0 {
		jitterRange = 1
	}
	jitter := time.Duration(rand.Int63n(jitterRange))
	if rand.Intn(2) == 0 {
		jitter = -jitter
	}
	d := base + jitter
	timer := time.NewTimer(d)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-timer.C:
		return nil
	}
}
