package github

import (
	"context"
	"errors"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"sync/atomic"
	"testing"
	"time"

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

type staticTokenSource struct{}

func (staticTokenSource) Token(context.Context, string) (githubapp.InstallationToken, error) {
	return githubapp.InstallationToken{Value: "test-token", ExpiresAt: time.Now().Add(time.Hour)}, nil
}
func (staticTokenSource) APIURL() string { return "https://api.github.test" }

type roundTripFunc func(*http.Request) (*http.Response, error)

func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) }

func testAPIClient(t *testing.T, transport http.RoundTripper) *apiClient {
	t.Helper()
	client, err := newAPIClient(staticTokenSource{}, "github-app-installation:1", &http.Client{Transport: transport})
	if err != nil {
		t.Fatal(err)
	}
	return client
}

func TestDoJSONPostTransportFailureIsResultUnknownAndNotRetried(t *testing.T) {
	var calls atomic.Int32
	client := testAPIClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) {
		calls.Add(1)
		return nil, io.ErrUnexpectedEOF
	}))
	err := client.doJSON(context.Background(), http.MethodPost, "/repos/o/r/issues", nil, map[string]any{"title": "x"}, nil)
	if !errors.Is(err, reposync.ErrResultUnknown) {
		t.Fatalf("expected result unknown, got %v", err)
	}
	if calls.Load() != 1 {
		t.Fatalf("non-idempotent POST was retried %d times", calls.Load())
	}
}

func TestDoJSONGetTransportFailureRetries(t *testing.T) {
	var calls atomic.Int32
	client := testAPIClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) {
		if calls.Add(1) == 1 {
			return nil, io.ErrUnexpectedEOF
		}
		return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"id":1}`)), Header: make(http.Header)}, nil
	}))
	var output map[string]any
	if err := client.doJSON(context.Background(), http.MethodGet, "/repos/o/r", nil, nil, &output); err != nil {
		t.Fatal(err)
	}
	if calls.Load() != 2 {
		t.Fatalf("GET calls = %d, want 2", calls.Load())
	}
}

func TestDoJSONPostServerErrorIsResultUnknown(t *testing.T) {
	var calls atomic.Int32
	client := testAPIClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) {
		calls.Add(1)
		return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("gateway failed")), Header: make(http.Header)}, nil
	}))
	err := client.doJSON(context.Background(), http.MethodPost, "/repos/o/r/pulls", nil, map[string]any{"title": "x"}, nil)
	if !errors.Is(err, reposync.ErrResultUnknown) {
		t.Fatalf("expected result unknown, got %v", err)
	}
	if calls.Load() != 1 {
		t.Fatalf("POST 502 was retried %d times", calls.Load())
	}
}

func TestDoJSONAddsRequiredGitHubHeaders(t *testing.T) {
	client := testAPIClient(t, roundTripFunc(func(request *http.Request) (*http.Response, error) {
		if request.Header.Get("Authorization") != "Bearer test-token" {
			t.Fatalf("unexpected Authorization header")
		}
		if request.Header.Get("X-GitHub-Api-Version") != "2022-11-28" {
			t.Fatalf("missing API version")
		}
		return &http.Response{StatusCode: http.StatusNoContent, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
	}))
	if err := client.doJSON(context.Background(), http.MethodDelete, "/repos/o/r/issues/comments/1", nil, nil, nil); err != nil {
		t.Fatal(err)
	}
}

func TestDoJSONClassifiesGitHubRateLimitAndPreservesReset(t *testing.T) {
	reset := time.Now().UTC().Add(10 * time.Minute).Truncate(time.Second)
	client := testAPIClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) {
		headers := make(http.Header)
		headers.Set("X-RateLimit-Remaining", "0")
		headers.Set("X-RateLimit-Reset", strconv.FormatInt(reset.Unix(), 10))
		return &http.Response{
			StatusCode: http.StatusForbidden,
			Header:     headers,
			Body:       io.NopCloser(strings.NewReader(`{"message":"API rate limit exceeded for installation"}`)),
		}, nil
	}))
	err := client.doJSON(context.Background(), http.MethodGet, "/repos/o/r/issues", nil, nil, nil)
	if !errors.Is(err, reposync.ErrRateLimited) {
		t.Fatalf("error = %v, want ErrRateLimited", err)
	}
	var limited *reposync.RateLimitError
	if !errors.As(err, &limited) || !limited.RetryAt.Equal(reset) {
		t.Fatalf("rate limit error = %#v, want retry at %s", limited, reset)
	}
}

func TestUploadAssetClassifiesGitHubRateLimitAndPreservesReset(t *testing.T) {
	reset := time.Now().UTC().Add(10 * time.Minute).Truncate(time.Second)
	client := testAPIClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) {
		headers := make(http.Header)
		headers.Set("X-RateLimit-Remaining", "0")
		headers.Set("X-RateLimit-Reset", strconv.FormatInt(reset.Unix(), 10))
		return &http.Response{
			StatusCode: http.StatusForbidden,
			Header:     headers,
			Body:       io.NopCloser(strings.NewReader(`{"message":"API rate limit exceeded for installation"}`)),
		}, nil
	}))
	err := client.uploadAsset(context.Background(), "https://uploads.github.com/repos/o/r/releases/1/assets", "release.zip", "", "application/zip", strings.NewReader("asset"), int64(len("asset")), nil)
	if !errors.Is(err, reposync.ErrRateLimited) {
		t.Fatalf("error = %v, want ErrRateLimited", err)
	}
	var limited *reposync.RateLimitError
	if !errors.As(err, &limited) || !limited.RetryAt.Equal(reset) {
		t.Fatalf("rate limit error = %#v, want retry at %s", limited, reset)
	}
}

func TestDoGraphQLUsesEnterpriseAPIEndpoint(t *testing.T) {
	var requested string
	client, err := newAPIClient(enterpriseTokenSource{}, "github-app-installation:1", &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
		requested = request.URL.String()
		return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":{"node":{"id":"R_1"}}}`)), Header: make(http.Header)}, nil
	})})
	if err != nil {
		t.Fatal(err)
	}
	var output struct {
		Node struct {
			ID string `json:"id"`
		} `json:"node"`
	}
	if err := client.doGraphQL(context.Background(), `query($id: ID!) { node(id: $id) { id } }`, map[string]any{"id": "R_1"}, &output); err != nil {
		t.Fatal(err)
	}
	if requested != "https://github.example.com/api/graphql" || output.Node.ID != "R_1" {
		t.Fatalf("requested=%q output=%#v", requested, output)
	}
}

func TestHTTPRedirectRejectsUnallowlistedTarget(t *testing.T) {
	var calls atomic.Int32
	client := testAPIClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) {
		calls.Add(1)
		return &http.Response{StatusCode: http.StatusFound, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{"Location": {"https://internal.example/secret"}}}, nil
	}))
	request, _ := http.NewRequest(http.MethodGet, "https://api.github.test/repos/o/r", nil)
	request.Header.Set("Authorization", "Bearer test-token")
	if _, err := client.doRequest(request, client.allowedAPIURL, request.URL.Hostname()); err == nil {
		t.Fatal("unallowlisted redirect was followed")
	}
	if calls.Load() != 1 {
		t.Fatalf("redirect transport calls = %d, want 1", calls.Load())
	}
}

func TestAssetRedirectDropsAuthorizationOnDifferentAllowedHost(t *testing.T) {
	var calls atomic.Int32
	client := testAPIClient(t, roundTripFunc(func(request *http.Request) (*http.Response, error) {
		if calls.Add(1) == 1 {
			return &http.Response{StatusCode: http.StatusFound, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{"Location": {"https://objects.githubusercontent.com/object"}}, Request: request}, nil
		}
		if request.Header.Get("Authorization") != "" {
			t.Fatal("Authorization header leaked to redirected asset host")
		}
		return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("asset")), Header: make(http.Header), Request: request}, nil
	}))
	request, _ := http.NewRequest(http.MethodGet, "https://api.github.test/assets/1", nil)
	request.Header.Set("Authorization", "Bearer test-token")
	assetURL, _ := url.Parse(request.URL.String())
	if _, err := client.doRequest(request, client.allowedAssetURL, assetURL.Hostname()); err != nil {
		t.Fatal(err)
	}
}
