package bunny

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync/atomic"
	"testing"
)

func newTestAPI(srvURL string) *API {
	api := NewAPI(srvURL, "zone", "key")
	api.http = &http.Client{Transport: rewriteTransport{}}
	return api
}

// rewriteTransport downgrades https://host/... to http://host/... for tests.
type rewriteTransport struct{}

func (rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	req.URL.Scheme = "http"
	return http.DefaultTransport.RoundTrip(req)
}

func TestUploadSuccess(t *testing.T) {
	var gotKey, gotChecksum, gotPath string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotKey = r.Header.Get("AccessKey")
		gotChecksum = r.Header.Get("Checksum")
		gotPath = r.URL.Path
		if r.Header.Get("Content-Type") != "" {
			t.Errorf("unexpected Content-Type %q", r.Header.Get("Content-Type"))
		}
		w.WriteHeader(201)
	}))
	defer srv.Close()

	api := newTestAPI(srv.URL)
	if err := api.Upload(context.Background(), "a/b.js", []byte("data")); err != nil {
		t.Fatalf("Upload: %v", err)
	}
	sum := sha256.Sum256([]byte("data"))
	wantChecksum := strings.ToUpper(hex.EncodeToString(sum[:]))
	if gotKey != "key" || gotChecksum != wantChecksum {
		t.Fatalf("headers: key=%q checksum=%q (want %q)", gotKey, gotChecksum, wantChecksum)
	}
	if gotPath != "/zone/a/b.js" {
		t.Fatalf("path = %q", gotPath)
	}
}

func TestUploadRetriesOn429WithRetryAfter(t *testing.T) {
	var n int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if atomic.AddInt32(&n, 1) <= 2 {
			w.Header().Set("Retry-After", "0")
			w.WriteHeader(429)
			return
		}
		w.WriteHeader(200)
	}))
	defer srv.Close()

	api := newTestAPI(srv.URL)
	if err := api.Upload(context.Background(), "x", []byte("d")); err != nil {
		t.Fatalf("Upload: %v", err)
	}
	if n != 3 {
		t.Fatalf("attempts = %d, want 3", n)
	}
}

func TestUploadFailsOn429NoRetryAfter(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(429)
	}))
	defer srv.Close()
	api := newTestAPI(srv.URL)
	if err := api.Upload(context.Background(), "x", []byte("d")); err == nil {
		t.Fatal("expected error")
	}
}

func TestUploadFailsOn5xx(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(500)
	}))
	defer srv.Close()
	api := newTestAPI(srv.URL)
	if err := api.Upload(context.Background(), "x", []byte("d")); err == nil {
		t.Fatal("expected error")
	}
}

func TestUploadRetriesExhausted(t *testing.T) {
	var n int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&n, 1)
		w.Header().Set("Retry-After", "0")
		w.WriteHeader(429)
	}))
	defer srv.Close()
	api := newTestAPI(srv.URL)
	if err := api.Upload(context.Background(), "x", []byte("d")); err == nil {
		t.Fatal("expected error")
	}
	if n != 4 { // initial + 3 retries
		t.Fatalf("attempts = %d, want 4", n)
	}
}

func TestUploadClassifiesStatus(t *testing.T) {
	cases := []struct {
		status int
		kind   errorKind
	}{
		{http.StatusUnauthorized, errorKindAuth},
		{http.StatusForbidden, errorKindAuth},
		{http.StatusBadRequest, errorKindInternal},
		{http.StatusNotFound, errorKindInternal},
		{http.StatusInternalServerError, errorKindServer},
		{http.StatusBadGateway, errorKindServer},
	}
	for _, tc := range cases {
		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.WriteHeader(tc.status)
		}))
		api := newTestAPI(srv.URL)
		err := api.Upload(context.Background(), "x", []byte("d"))
		srv.Close()

		var apiErr *apiError
		if !errors.As(err, &apiErr) {
			t.Fatalf("status %d: want *APIError, got %T (%v)", tc.status, err, err)
		}
		if apiErr.kind != tc.kind {
			t.Fatalf("status %d: kind = %d, want %d", tc.status, apiErr.kind, tc.kind)
		}
		if apiErr.status != tc.status {
			t.Fatalf("status %d: APIError.Status = %d", tc.status, apiErr.status)
		}
	}
}

func TestUploadSurfacesBunnyErrorMessage(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(`{"HttpCode":400,"Message":"checksum mismatch"}`))
	}))
	defer srv.Close()

	api := newTestAPI(srv.URL)
	err := api.Upload(context.Background(), "x", []byte("d"))

	var apiErr *apiError
	if !errors.As(err, &apiErr) {
		t.Fatalf("want *apiError, got %T (%v)", err, err)
	}
	if got := apiErr.ErrorVerbose(); !strings.Contains(got, "checksum mismatch") {
		t.Fatalf("verbose detail = %q, want it to contain Bunny's Message", got)
	}
}

func TestListParsesFullPaths(t *testing.T) {
	var gotPath string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		w.Write([]byte(`[
			{"Path":"/zone/assets/","ObjectName":"app.abc.js","Length":14,"IsDirectory":false,"LastChanged":"2023-06-29T12:00:00.000"},
			{"Path":"/zone/assets/","ObjectName":"sub","Length":0,"IsDirectory":true,"LastChanged":"2023-06-29T12:00:00"}
		]`))
	}))
	defer srv.Close()

	api := newTestAPI(srv.URL)
	objs, err := api.listDir(context.Background(), "assets")
	if err != nil {
		t.Fatalf("listDir: %v", err)
	}
	if gotPath != "/zone/assets/" {
		t.Fatalf("list path = %q, want trailing slash", gotPath)
	}
	if len(objs) != 2 {
		t.Fatalf("objs = %v", objs)
	}
	if objs[0].Path != "assets/app.abc.js" || objs[0].Size != 14 || objs[0].IsDirectory {
		t.Fatalf("file object = %+v", objs[0])
	}
	if objs[1].Path != "assets/sub" || !objs[1].IsDirectory {
		t.Fatalf("dir object = %+v", objs[1])
	}
}

func TestListRootTrailingSlash(t *testing.T) {
	var gotPath string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		w.Write([]byte(`[]`))
	}))
	defer srv.Close()

	api := newTestAPI(srv.URL)
	if _, err := api.listDir(context.Background(), ""); err != nil {
		t.Fatalf("listDir: %v", err)
	}
	if gotPath != "/zone/" {
		t.Fatalf("root list path = %q, want /zone/", gotPath)
	}
}

func TestWalkBuildsTreeAndDescends(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/zone/":
			w.Write([]byte(`[
				{"Path":"/zone/","ObjectName":"index.html","Length":6,"IsDirectory":false},
				{"Path":"/zone/","ObjectName":"assets","IsDirectory":true}
			]`))
		case "/zone/assets/":
			w.Write([]byte(`[{"Path":"/zone/assets/","ObjectName":"app.js","Length":3,"IsDirectory":false}]`))
		default:
			t.Errorf("unexpected list path %q", r.URL.Path)
		}
	}))
	defer srv.Close()

	api := newTestAPI(srv.URL)
	it := api.List(context.Background(), "")
	var got []Object
	for it.Next() {
		got = append(got, it.Object())
	}
	if err := it.Err(); err != nil {
		t.Fatalf("Walk: %v", err)
	}
	if len(got) != 3 {
		t.Fatalf("got %d entries: %+v", len(got), got)
	}
	var sawDir, sawNested bool
	for _, o := range got {
		if o.Path == "assets" && o.IsDirectory {
			sawDir = true
		}
		if o.Path == "assets/app.js" && !o.IsDirectory && o.Size == 3 {
			sawNested = true
		}
	}
	if !sawDir || !sawNested {
		t.Fatalf("expected descent into assets; got %+v", got)
	}
}

func TestWalk404IsEmpty(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(404)
	}))
	defer srv.Close()

	api := newTestAPI(srv.URL)
	it := api.List(context.Background(), "")
	for it.Next() {
		t.Fatalf("unexpected entry %+v", it.Object())
	}
	if err := it.Err(); err != nil {
		t.Fatalf("Walk: %v", err)
	}
}

func TestWalkFatalOnNon404(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(500)
	}))
	defer srv.Close()

	api := newTestAPI(srv.URL)
	it := api.List(context.Background(), "")
	for it.Next() {
	}
	if it.Err() == nil {
		t.Fatal("expected error")
	}
}

func TestUploadRateLimitedIsTyped(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(429)
	}))
	defer srv.Close()
	api := newTestAPI(srv.URL)
	err := api.Upload(context.Background(), "x", []byte("d"))
	var apiErr *apiError
	if !errors.As(err, &apiErr) || apiErr.kind != errorKindRateLimited {
		t.Fatalf("want KindRateLimited APIError, got %v", err)
	}
}

func TestDeleteSuccess(t *testing.T) {
	var gotMethod, gotKey, gotPath string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotMethod = r.Method
		gotKey = r.Header.Get("AccessKey")
		gotPath = r.URL.Path
		w.WriteHeader(200)
	}))
	defer srv.Close()

	api := newTestAPI(srv.URL)
	if err := api.Delete(context.Background(), "a/b.js"); err != nil {
		t.Fatalf("Delete: %v", err)
	}
	if gotMethod != http.MethodDelete {
		t.Fatalf("method = %q", gotMethod)
	}
	if gotKey != "key" || gotPath != "/zone/a/b.js" {
		t.Fatalf("key=%q path=%q", gotKey, gotPath)
	}
}

func TestDeleteFailsOn5xx(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(500)
	}))
	defer srv.Close()

	api := newTestAPI(srv.URL)
	if err := api.Delete(context.Background(), "a/b.js"); err == nil {
		t.Fatal("expected error on 5xx")
	}
}

// TestAPIErrorUnwrapsCause ensures a network apiError preserves its cause in the
// error chain, so errors.Is can detect e.g. context.Canceled from an aborted
// in-flight upload. Without this, the worker pool cannot filter cancellation
// collateral out of a fail-fast failure report.
func TestAPIErrorUnwrapsCause(t *testing.T) {
	req, err := http.NewRequest(http.MethodPut, "https://example.test/zone/a.js", nil)
	if err != nil {
		t.Fatal(err)
	}
	// Simulate a transport error whose cause is context.Canceled.
	apiErr := newAPIError(errorKindNetwork, context.Canceled, req, 0)
	if !errors.Is(apiErr, context.Canceled) {
		t.Fatalf("apiError must unwrap to its cause; got chain that is not context.Canceled")
	}
}
