package tags

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

// httpClientOptions configures a httpClient. A trimmed sibling of the
// coverart HTTP helper — JSON-only (tag APIs return no binary), plus a
// User-Agent field because MusicBrainz rejects requests without one.
// Kept local to this package so tag enrichment never depends on
// coverart's internals.
type httpClientOptions struct {
	HTTPClient *http.Client
	// Name prefixes wrapped error messages (e.g. "tags lastfm: ...").
	Name string
	// UserAgent is sent on every request. MusicBrainz mandates a
	// descriptive one; empty omits the header.
	UserAgent 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 (2^attempt × base ± 20%).
	BaseBackoff time.Duration
	// TreatAuthAsTransient maps 401/403 to ErrTransient (keyed providers
	// want this so a bad/expired key retries rather than settling 'none').
	TreatAuthAsTransient bool
	// JSONBodyLimit caps the response body read.
	JSONBodyLimit int64
}

// httpClient performs rate-limited, retrying JSON GETs. Each provider
// owns one, configured with provider-specific intervals + auth semantics.
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
	}
	return &httpClient{opts: opts}
}

// getJSON sends GET fullURL and decodes the 200 body into out (capped at
// JSONBodyLimit). Retries 429/5xx with exponential backoff up to
// MaxRetries. 404 → ErrNotFound; other 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("tags %s: build request: %w", c.opts.Name, err)
		}
		req.Header.Set("Accept", "application/json")
		if c.opts.UserAgent != "" {
			req.Header.Set("User-Agent", c.opts.UserAgent)
		}

		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)
		}

		retry, ferr := c.handleStatus(ctx, resp, attempt)
		if retry {
			continue
		}
		if ferr != nil {
			return ferr
		}

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

// handleStatus classifies a response. Returns retry=true to loop again
// (after backing off), or a terminal error, or (false, nil) to proceed to
// body read. Closes the body on every path except the proceed case.
func (c *httpClient) handleStatus(ctx context.Context, resp *http.Response, attempt int) (retry bool, err error) {
	switch {
	case resp.StatusCode == http.StatusNotFound:
		_ = resp.Body.Close()
		return false, ErrNotFound
	case c.opts.TreatAuthAsTransient &&
		(resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden):
		_ = resp.Body.Close()
		return false, 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 false, waitErr
			}
			return true, nil
		}
		return false, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
	case resp.StatusCode != http.StatusOK:
		_ = resp.Body.Close()
		return false, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
	}
	return false, nil
}

// rateLimitWait blocks until 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
	}
	timer := time.NewTimer(base + jitter)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-timer.C:
		return nil
	}
}
