package bunny

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

	"tsne.dev/hopper/internal/log"
)

// Object is a single node returned by a directory listing.
type Object struct {
	Path        string
	Size        int
	IsDirectory bool
	LastChanged time.Time
}

type API struct {
	endpoint   string // hostname only (normalized, no scheme/trailing slash)
	zone       string
	accessKey  string
	maxRetries int
	http       *http.Client
}

func NewAPI(endpoint, zone, accessKey string) *API {
	// normalize endpoint
	endpoint = strings.TrimPrefix(endpoint, "https://")
	endpoint = strings.TrimPrefix(endpoint, "http://")
	endpoint = strings.TrimRight(endpoint, "/")

	return &API{
		endpoint:   endpoint,
		zone:       zone,
		accessKey:  accessKey,
		maxRetries: 3,
		http:       &http.Client{},
	}
}

// do executes req under the shared retry policy and returns the final
// response (2xx) or a typed *apiError. It owns the universal parts: the
// AccessKey header, the 429+Retry-After retry loop, and status classification.
// Callers own method, body, and op-specific headers, and must read/close the
// returned body. method and path on any apiError are taken from req. The body
// is reset from req.GetBody before each attempt; callers that send a body MUST
// build the request with bytes.NewReader (so GetBody is set) or retries would
// resend an empty body.
func (api *API) do(ctx context.Context, req *http.Request) (*http.Response, error) {
	req.Header.Set("AccessKey", api.accessKey)

	attempt := 0
	for {
		attempt++
		if req.GetBody != nil {
			body, err := req.GetBody()
			if err != nil {
				return nil, newAPIError(errorKindNetwork, err, req, 0)
			}
			req.Body = body
		}

		resp, err := api.http.Do(req)
		if err != nil {
			if ctxErr := context.Cause(ctx); ctxErr != nil {
				return nil, ctxErr
			}
			return nil, newAPIError(errorKindNetwork, err, req, 0)
		}

		if resp.StatusCode >= 200 && resp.StatusCode < 300 {
			return resp, nil
		}

		bunnyMsg := readResponseError(resp.Body)
		resp.Body.Close()

		if resp.StatusCode == http.StatusTooManyRequests {
			ra := resp.Header.Get("Retry-After")
			if ra == "" {
				return nil, newAPIError(errorKindRateLimited, errors.New("status 429, no retry-after header received"), req, 0)
			}
			if attempt > api.maxRetries {
				return nil, newAPIError(errorKindRateLimited, errors.New("status 429, retries exhausted"), req, 0)
			}
			d, perr := parseRetryAfter(ra)
			if perr != nil {
				return nil, newAPIError(errorKindRateLimited, errors.New("status 429, "+perr.Error()), req, 0)
			}
			// Surface the cool-down so a long Retry-After wait doesn't look like a
			// hang. Always warned (never suppressed): being rate-limited is
			// abnormal and the wait is user-visible time.
			log.Warn("rate-limited by Bunny (retry in %s; attempt %d/%d)", d.Round(time.Second), attempt, api.maxRetries)
			select {
			case <-ctx.Done():
				return nil, context.Cause(ctx)
			case <-time.After(d):
			}
			continue
		}

		errKind := errorKindFromStatus(resp.StatusCode)
		var cause error
		if bunnyMsg != "" {
			cause = fmt.Errorf("http error %d: %s", resp.StatusCode, bunnyMsg)
		} else {
			cause = fmt.Errorf("http error %d", resp.StatusCode)
		}
		return nil, newAPIError(errKind, cause, req, resp.StatusCode)
	}
}

// readResponseError best-effort extracts Bunny's ErrorObject.Message from a
// non-2xx response body. It fully drains (bounded) the body so the connection
// can be reused, and returns "" when the body is absent, unparseable, or
// carries no Message. It never errors: enrichment is optional detail.
func readResponseError(body io.Reader) string {
	data, err := io.ReadAll(io.LimitReader(body, 8<<10)) // 8 KiB
	if err != nil || len(data) == 0 {
		return ""
	}
	var e struct {
		Message string `json:"Message"`
	}
	if err := json.Unmarshal(data, &e); err != nil {
		return ""
	}
	return strings.TrimSpace(e.Message)
}

func (api *API) url(path string) string {
	return "https://" + api.endpoint + "/" + api.zone + "/" + path
}

// parseTime parses Bunny's LastChanged timestamp, which is NOT RFC 3339
// (it carries no timezone, e.g. "2023-06-29T12:00:00.000"), so it cannot be
// unmarshaled into a time.Time directly.
func parseTime(v string) time.Time {
	if v == "" {
		return time.Time{}
	}
	if t, err := time.Parse("2006-01-02T15:04:05.999", v); err == nil {
		return t.Local()
	}
	if t, err := time.Parse(time.RFC3339, v); err == nil {
		return t.Local()
	}
	return time.Time{}
}

// parseRetryAfter handles the delay-seconds form and the HTTP-date form.
func parseRetryAfter(v string) (time.Duration, error) {
	if secs, err := strconv.Atoi(v); err == nil {
		if secs < 0 {
			return 0, fmt.Errorf("negative retry-after header")
		}
		return time.Duration(secs) * time.Second, nil
	}
	if t, err := http.ParseTime(v); err == nil {
		return max(0, time.Until(t)), nil
	}
	return 0, fmt.Errorf("unparseable retry-after header %q", v)
}
