package sources

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"time"
)

// ErrUnauthorized is returned when an API rejects credentials (401).
var ErrUnauthorized = errors.New("sources: unauthorized (check credentials)")

// Client is the shared retry wrapper every Phase 10 source uses.
//
// It handles 429/403/5xx retries honoring Retry-After, context cancellation
// during backoff, and wraps 401 responses in ErrUnauthorized. Rate limiting
// is intentionally out of scope — callers call recon.LimiterRegistry.Wait
// before Do to keep this type single-purpose.
type Client struct {
	HTTP       *http.Client
	MaxRetries int    // default 2
	UserAgent  string // default "keyhunter-recon/1.0"
}

// NewClient returns a Client with a 30s timeout and 2 retries.
func NewClient() *Client {
	return &Client{
		HTTP:       &http.Client{Timeout: 30 * time.Second},
		MaxRetries: 2,
		UserAgent:  "keyhunter-recon/1.0",
	}
}

// Do executes req with retries on 429/403/5xx honoring Retry-After.
// 401 returns ErrUnauthorized wrapped with the response body.
// Ctx cancellation is honored both during the request and during retry sleeps.
func (c *Client) Do(ctx context.Context, req *http.Request) (*http.Response, error) {
	if req.Header.Get("User-Agent") == "" {
		req.Header.Set("User-Agent", c.UserAgent)
	}

	var lastStatus int
	var lastBody string
	for attempt := 0; attempt <= c.MaxRetries; attempt++ {
		r, err := c.HTTP.Do(req.WithContext(ctx))
		if err != nil {
			return nil, fmt.Errorf("sources http: %w", err)
		}

		if r.StatusCode == http.StatusOK {
			return r, nil
		}

		if r.StatusCode == http.StatusUnauthorized {
			body := readBody(r)
			return nil, fmt.Errorf("%w: %s", ErrUnauthorized, body)
		}

		retriable := r.StatusCode == http.StatusTooManyRequests ||
			r.StatusCode == http.StatusForbidden ||
			r.StatusCode >= 500
		if !retriable || attempt == c.MaxRetries {
			body := readBody(r)
			return nil, fmt.Errorf("sources http %d: %s", r.StatusCode, body)
		}

		sleep := ParseRetryAfter(r.Header.Get("Retry-After"))
		lastStatus = r.StatusCode
		lastBody = readBody(r)

		select {
		case <-time.After(sleep):
		case <-ctx.Done():
			return nil, ctx.Err()
		}
	}

	return nil, fmt.Errorf("sources http: retries exhausted (last %d: %s)", lastStatus, lastBody)
}

// ParseRetryAfter decodes integer-seconds Retry-After, defaulting to 1s when
// the header is missing, unparseable, or zero.
func ParseRetryAfter(v string) time.Duration {
	if v == "" {
		return 1 * time.Second
	}
	n, err := strconv.Atoi(v)
	if err != nil || n <= 0 {
		return 1 * time.Second
	}
	return time.Duration(n) * time.Second
}

// readBody reads up to 4KB of the response body and closes it.
func readBody(r *http.Response) string {
	if r.Body == nil {
		return ""
	}
	defer r.Body.Close()
	b, err := io.ReadAll(io.LimitReader(r.Body, 4096))
	if err != nil {
		return ""
	}
	return string(b)
}
