package github

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

	reposync "github.com/pingcap/agent-git-service/internal/sync"
	"github.com/pingcap/agent-git-service/internal/sync/githubapp"
)

const maxJSONResponseBytes = 32 << 20

type TokenSource interface {
	Token(context.Context, string) (githubapp.InstallationToken, error)
	APIURL() string
}

type apiClient struct {
	baseURL      string
	connectionID string
	tokens       TokenSource
	http         *http.Client
}

type APIError struct {
	Method             string
	URL                string
	StatusCode         int
	Body               string
	RetryAfter         time.Duration
	RateLimitRemaining string
	RateLimitResetAt   time.Time
}

func (e *APIError) Error() string {
	return fmt.Sprintf("GitHub %s %s: HTTP %d: %s", e.Method, e.URL, e.StatusCode, e.Body)
}

func newAPIClient(tokens TokenSource, connectionID string, httpClient *http.Client) (*apiClient, error) {
	if tokens == nil {
		return nil, fmt.Errorf("GitHub token source is required")
	}
	baseURL := strings.TrimRight(tokens.APIURL(), "/")
	if _, err := url.ParseRequestURI(baseURL); err != nil {
		return nil, fmt.Errorf("invalid GitHub API URL: %w", err)
	}
	if httpClient == nil {
		httpClient = &http.Client{Timeout: 60 * time.Second}
	}
	return &apiClient{baseURL: baseURL, connectionID: connectionID, tokens: tokens, http: httpClient}, nil
}

func (c *apiClient) list(ctx context.Context, path string, query url.Values) ([]json.RawMessage, error) {
	if query == nil {
		query = make(url.Values)
	} else {
		query = cloneValues(query)
	}
	query.Set("per_page", "100")
	query.Set("page", "1")
	var all []json.RawMessage
	for {
		var page []json.RawMessage
		if err := c.doJSON(ctx, http.MethodGet, path, query, nil, &page); err != nil {
			return nil, err
		}
		all = append(all, page...)
		if len(page) < 100 {
			return all, nil
		}
		current, _ := strconv.Atoi(query.Get("page"))
		query.Set("page", strconv.Itoa(current+1))
	}
}

func (c *apiClient) hasAny(ctx context.Context, path string, query url.Values) (bool, error) {
	if query == nil {
		query = make(url.Values)
	} else {
		query = cloneValues(query)
	}
	query.Set("per_page", "1")
	query.Set("page", "1")
	var page []json.RawMessage
	if err := c.doJSON(ctx, http.MethodGet, path, query, nil, &page); err != nil {
		return false, err
	}
	return len(page) != 0, nil
}

func (c *apiClient) doJSON(ctx context.Context, method, path string, query url.Values, input, output any) error {
	endpoint := c.baseURL + "/" + strings.TrimLeft(path, "/")
	if len(query) != 0 {
		endpoint += "?" + query.Encode()
	}
	return c.doJSONEndpoint(ctx, method, endpoint, input, output)
}

func (c *apiClient) doJSONEndpoint(ctx context.Context, method, endpoint string, input, output any) error {
	var body io.Reader
	if input != nil {
		payload, err := json.Marshal(input)
		if err != nil {
			return err
		}
		body = bytes.NewReader(payload)
	}
	var lastErr error
	for attempt := 0; attempt < 4; attempt++ {
		token, err := c.tokens.Token(ctx, c.connectionID)
		if err != nil {
			return err
		}
		if seeker, ok := body.(io.Seeker); ok {
			_, _ = seeker.Seek(0, io.SeekStart)
		}
		req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
		if err != nil {
			return err
		}
		req.Header.Set("Authorization", "Bearer "+token.Value)
		req.Header.Set("Accept", "application/vnd.github+json")
		req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
		if input != nil {
			req.Header.Set("Content-Type", "application/json")
		}
		resp, err := c.doRequest(req, c.allowedAPIURL, req.URL.Hostname())
		if err != nil {
			if method == http.MethodPost {
				return fmt.Errorf("%w: GitHub %s outcome after transport failure: %v", reposync.ErrResultUnknown, method, err)
			}
			lastErr = err
			if !waitRetry(ctx, time.Duration(attempt+1)*250*time.Millisecond) {
				return ctx.Err()
			}
			continue
		}
		payload, readErr := io.ReadAll(io.LimitReader(resp.Body, maxJSONResponseBytes+1))
		_ = resp.Body.Close()
		if readErr != nil {
			if method == http.MethodPost {
				return fmt.Errorf("%w: GitHub %s response could not be read: %v", reposync.ErrResultUnknown, method, readErr)
			}
			return readErr
		}
		if len(payload) > maxJSONResponseBytes {
			if method == http.MethodPost {
				return fmt.Errorf("%w: GitHub %s succeeded with an oversized response", reposync.ErrResultUnknown, method)
			}
			return fmt.Errorf("GitHub response exceeds %d bytes", maxJSONResponseBytes)
		}
		if resp.StatusCode >= 200 && resp.StatusCode < 300 {
			if output == nil || len(bytes.TrimSpace(payload)) == 0 {
				return nil
			}
			if err := json.Unmarshal(payload, output); err != nil {
				if method == http.MethodPost {
					return fmt.Errorf("%w: GitHub %s succeeded but its identity response was invalid: %v", reposync.ErrResultUnknown, method, err)
				}
				return err
			}
			return nil
		}
		apiErr := newAPIError(method, endpoint, resp, payload)
		lastErr = apiErr
		if method == http.MethodPost && resp.StatusCode >= http.StatusInternalServerError {
			return fmt.Errorf("%w: %v", reposync.ErrResultUnknown, apiErr)
		}
		// A GitHub installation limit can last minutes or an hour. Do not keep a
		// Worker lease while sleeping: return a typed error so the durable Run is
		// surfaced as rate-limited and can be safely retried after reset.
		if isRateLimited(apiErr) {
			return classifyAPIResponseError(apiErr)
		}
		retryable := retryableStatus(resp.StatusCode)
		if resp.StatusCode == http.StatusForbidden && apiErr.RetryAfter <= 0 {
			retryable = false
		}
		if !retryable || attempt == 3 {
			return classifyAPIResponseError(apiErr)
		}
		delay := apiErr.RetryAfter
		if delay <= 0 {
			delay = time.Duration(1<<attempt) * time.Second
		}
		if !waitRetry(ctx, delay) {
			return ctx.Err()
		}
	}
	return lastErr
}

func classifyAPIResponseError(apiErr *APIError) error {
	if apiErr == nil {
		return nil
	}
	if isRateLimited(apiErr) {
		retryAt := apiErr.RateLimitResetAt
		if retryAt.IsZero() && apiErr.RetryAfter > 0 {
			retryAt = time.Now().UTC().Add(apiErr.RetryAfter)
		}
		return &reposync.RateLimitError{RetryAt: retryAt, Cause: apiErr}
	}
	if apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden {
		return fmt.Errorf("%w: %v", reposync.ErrPermission, apiErr)
	}
	return apiErr
}

func newAPIError(method, endpoint string, response *http.Response, payload []byte) *APIError {
	apiErr := &APIError{
		Method:             method,
		URL:                endpoint,
		StatusCode:         response.StatusCode,
		Body:               strings.TrimSpace(string(payload)),
		RetryAfter:         retryAfter(response),
		RateLimitRemaining: strings.TrimSpace(response.Header.Get("X-RateLimit-Remaining")),
	}
	if reset, err := strconv.ParseInt(strings.TrimSpace(response.Header.Get("X-RateLimit-Reset")), 10, 64); err == nil && reset > 0 {
		apiErr.RateLimitResetAt = time.Unix(reset, 0).UTC()
		if apiErr.RetryAfter <= 0 {
			apiErr.RetryAfter = time.Until(apiErr.RateLimitResetAt)
		}
	}
	return apiErr
}

func isRateLimited(apiErr *APIError) bool {
	if apiErr == nil {
		return false
	}
	if apiErr.StatusCode == http.StatusTooManyRequests {
		return true
	}
	if apiErr.StatusCode != http.StatusForbidden {
		return false
	}
	if apiErr.RetryAfter > 0 || apiErr.RateLimitRemaining == "0" {
		return true
	}
	body := strings.ToLower(apiErr.Body)
	return strings.Contains(body, "rate limit") || strings.Contains(body, "secondary rate limit")
}

func (c *apiClient) doGraphQL(ctx context.Context, query string, variables map[string]any, output any) error {
	base, err := url.Parse(c.baseURL)
	if err != nil {
		return err
	}
	path := strings.TrimRight(base.Path, "/")
	if strings.HasSuffix(path, "/api/v3") {
		path = strings.TrimSuffix(path, "/api/v3") + "/api/graphql"
	} else {
		path += "/graphql"
	}
	base.Path, base.RawQuery, base.Fragment = path, "", ""
	var envelope struct {
		Data   json.RawMessage `json:"data"`
		Errors []struct {
			Message string `json:"message"`
		} `json:"errors"`
	}
	if err := c.doJSONEndpoint(ctx, http.MethodPost, base.String(), map[string]any{"query": query, "variables": variables}, &envelope); err != nil {
		return err
	}
	if len(envelope.Errors) != 0 {
		messages := make([]string, 0, len(envelope.Errors))
		for _, item := range envelope.Errors {
			messages = append(messages, item.Message)
		}
		return fmt.Errorf("GitHub GraphQL: %s", strings.Join(messages, "; "))
	}
	if output == nil {
		return nil
	}
	return json.Unmarshal(envelope.Data, output)
}

func (c *apiClient) download(ctx context.Context, endpoint string, maxBytes int64) ([]byte, string, error) {
	if maxBytes <= 0 {
		maxBytes = 2 << 30
	}
	body, contentType, contentLength, err := c.openDownload(ctx, endpoint, maxBytes)
	if err != nil {
		return nil, "", err
	}
	defer body.Close()
	payload, err := io.ReadAll(io.LimitReader(body, maxBytes+1))
	if err != nil {
		return nil, "", err
	}
	if int64(len(payload)) > maxBytes || (contentLength >= 0 && int64(len(payload)) != contentLength) {
		return nil, "", fmt.Errorf("GitHub asset exceeds or does not match declared size")
	}
	return payload, contentType, nil
}

func (c *apiClient) openDownload(ctx context.Context, endpoint string, maxBytes int64) (io.ReadCloser, string, int64, error) {
	parsed, err := url.Parse(endpoint)
	if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
		return nil, "", 0, fmt.Errorf("GitHub asset URL is not an absolute HTTPS URL")
	}
	if !c.allowedAssetURL(parsed) {
		return nil, "", 0, fmt.Errorf("GitHub asset host %q is not allowlisted", parsed.Hostname())
	}
	token, err := c.tokens.Token(ctx, c.connectionID)
	if err != nil {
		return nil, "", 0, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, "", 0, err
	}
	req.Header.Set("Authorization", "Bearer "+token.Value)
	req.Header.Set("Accept", "application/octet-stream")
	apiBase, _ := url.Parse(c.baseURL)
	resp, err := c.doRequest(req, c.allowedAssetURL, apiBase.Hostname())
	if err != nil {
		return nil, "", 0, err
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		_ = resp.Body.Close()
		return nil, "", 0, &APIError{Method: http.MethodGet, URL: redactURL(endpoint), StatusCode: resp.StatusCode}
	}
	if maxBytes <= 0 {
		maxBytes = 2 << 30
	}
	if resp.ContentLength > maxBytes {
		_ = resp.Body.Close()
		return nil, "", 0, fmt.Errorf("GitHub asset exceeds %d bytes", maxBytes)
	}
	return resp.Body, resp.Header.Get("Content-Type"), resp.ContentLength, nil
}

func redactURL(value string) string {
	parsed, err := url.Parse(value)
	if err != nil {
		return "[invalid URL]"
	}
	parsed.RawQuery, parsed.Fragment, parsed.User = "", "", nil
	return parsed.String()
}

func (c *apiClient) uploadAsset(ctx context.Context, uploadURL, name, label, contentType string, body io.Reader, size int64, output any) error {
	uploadURL = strings.Split(uploadURL, "{")[0]
	parsed, err := url.Parse(uploadURL)
	if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
		return fmt.Errorf("GitHub upload URL is not absolute HTTPS")
	}
	apiBase, _ := url.Parse(c.baseURL)
	if parsed.Hostname() != apiBase.Hostname() && parsed.Hostname() != "uploads.github.com" {
		return fmt.Errorf("GitHub upload host %q is not allowlisted", parsed.Hostname())
	}
	query := parsed.Query()
	query.Set("name", name)
	if label != "" {
		query.Set("label", label)
	}
	parsed.RawQuery = query.Encode()
	token, err := c.tokens.Token(ctx, c.connectionID)
	if err != nil {
		return err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), body)
	if err != nil {
		return err
	}
	req.ContentLength = size
	req.Header.Set("Authorization", "Bearer "+token.Value)
	req.Header.Set("Accept", "application/vnd.github+json")
	req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
	req.Header.Set("Content-Type", firstNonEmpty(contentType, "application/octet-stream"))
	resp, err := c.doRequest(req, c.allowedUploadURL, parsed.Hostname())
	if err != nil {
		return fmt.Errorf("%w: upload GitHub release asset: %v", reposync.ErrResultUnknown, err)
	}
	defer resp.Body.Close()
	payload, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONResponseBytes+1))
	if err != nil {
		return fmt.Errorf("%w: GitHub release asset response could not be read: %v", reposync.ErrResultUnknown, err)
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		// Asset uploads use the same installation quota as the rest of the
		// GitHub API. Preserve the quota headers here too, so a limit becomes a
		// safe, retryable pause rather than a misleading permission error.
		apiErr := newAPIError(http.MethodPost, redactURL(parsed.String()), resp, payload)
		if resp.StatusCode >= http.StatusInternalServerError {
			return fmt.Errorf("%w: %v", reposync.ErrResultUnknown, apiErr)
		}
		return classifyAPIResponseError(apiErr)
	}
	if output != nil {
		if err := json.Unmarshal(payload, output); err != nil {
			return fmt.Errorf("%w: GitHub release asset uploaded but its identity response was invalid: %v", reposync.ErrResultUnknown, err)
		}
	}
	return nil
}

func (c *apiClient) doRequest(request *http.Request, allowed func(*url.URL) bool, authorizationHost string) (*http.Response, error) {
	client := *c.http
	previous := client.CheckRedirect
	client.CheckRedirect = func(next *http.Request, via []*http.Request) error {
		if len(via) >= 10 {
			return fmt.Errorf("too many GitHub HTTP redirects")
		}
		if allowed == nil || !allowed(next.URL) {
			return fmt.Errorf("GitHub redirect target is not allowlisted")
		}
		if !strings.EqualFold(next.URL.Hostname(), authorizationHost) {
			next.Header.Del("Authorization")
		}
		if previous != nil {
			return previous(next, via)
		}
		return nil
	}
	return client.Do(request)
}

func (c *apiClient) allowedAPIURL(candidate *url.URL) bool {
	base, _ := url.Parse(c.baseURL)
	return candidate != nil && strings.EqualFold(candidate.Scheme, base.Scheme) && strings.EqualFold(candidate.Hostname(), base.Hostname())
}

func (c *apiClient) allowedAssetURL(candidate *url.URL) bool {
	if candidate == nil || candidate.Scheme != "https" || candidate.Hostname() == "" {
		return false
	}
	base, _ := url.Parse(c.baseURL)
	host := strings.ToLower(candidate.Hostname())
	return strings.EqualFold(host, base.Hostname()) || host == "github.com" || strings.HasSuffix(host, ".githubusercontent.com")
}

func (c *apiClient) allowedUploadURL(candidate *url.URL) bool {
	if candidate == nil || candidate.Scheme != "https" || candidate.Hostname() == "" {
		return false
	}
	base, _ := url.Parse(c.baseURL)
	return strings.EqualFold(candidate.Hostname(), base.Hostname()) || strings.EqualFold(candidate.Hostname(), "uploads.github.com")
}

func retryAfter(resp *http.Response) time.Duration {
	if seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil && seconds > 0 {
		return time.Duration(seconds) * time.Second
	}
	if reset, err := strconv.ParseInt(strings.TrimSpace(resp.Header.Get("X-RateLimit-Reset")), 10, 64); err == nil {
		return time.Until(time.Unix(reset, 0))
	}
	return 0
}

func retryableStatus(status int) bool {
	return status == http.StatusTooManyRequests || status == http.StatusForbidden || status == http.StatusBadGateway || status == http.StatusServiceUnavailable || status == http.StatusGatewayTimeout
}

func waitRetry(ctx context.Context, delay time.Duration) bool {
	timer := time.NewTimer(delay)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return false
	case <-timer.C:
		return true
	}
}

func cloneValues(input url.Values) url.Values {
	output := make(url.Values, len(input))
	for key, values := range input {
		output[key] = append([]string(nil), values...)
	}
	return output
}

func isNotFound(err error) bool {
	var apiErr *APIError
	return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound
}

func firstNonEmpty(value, fallback string) string {
	if strings.TrimSpace(value) == "" {
		return fallback
	}
	return value
}
