package server

// Test-only HTTP helpers for exercising the server API. These replace the
// removed drive9 Go SDK (pkg/client) inside server tests: they speak the raw
// HTTP endpoints and are not shipped as a client library.

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

	"github.com/mem9-ai/drive9/pkg/backend"
	"github.com/mem9-ai/drive9/pkg/s3client"
)

// errConflict reports an HTTP 409 write conflict from the server.
var errConflict = errors.New("conflict")

// apiStatusError preserves the HTTP status code for API errors.
type apiStatusError struct {
	StatusCode int
	Message    string
}

func (e *apiStatusError) Error() string {
	if e.Message != "" {
		return e.Message
	}
	return fmt.Sprintf("HTTP %d", e.StatusCode)
}

func (e *apiStatusError) Is(target error) bool {
	return target == errConflict && e.StatusCode == http.StatusConflict
}

// apiIsNotFound reports whether err is an HTTP 404 apiStatusError.
func apiIsNotFound(err error) bool {
	var se *apiStatusError
	return errors.As(err, &se) && se.StatusCode == http.StatusNotFound
}

type apiSearchResult struct {
	Path string `json:"path"`
}

type apiTestClient struct {
	baseURL string
	hc      *http.Client
}

func newAPITestClient(baseURL string) *apiTestClient {
	return &apiTestClient{baseURL: baseURL, hc: http.DefaultClient}
}

func (c *apiTestClient) do(ctx context.Context, method, u string, body io.Reader, contentType string) (*http.Response, error) {
	req, err := http.NewRequestWithContext(ctx, method, u, body)
	if err != nil {
		return nil, err
	}
	if contentType != "" {
		req.Header.Set("Content-Type", contentType)
	}
	return c.hc.Do(req)
}

func apiReadError(resp *http.Response) error {
	body, _ := io.ReadAll(resp.Body)
	msg := strings.TrimSpace(string(body))
	if len(msg) > 512 {
		msg = msg[:512]
	}
	return &apiStatusError{StatusCode: resp.StatusCode, Message: msg}
}

func decodeJSON[T any](resp *http.Response) (*T, error) {
	var out T
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	return &out, nil
}

func (c *apiTestClient) CreateFSLayer(ctx context.Context, req fsLayerCreateRequest) (*fsLayerResponse, error) {
	body, err := json.Marshal(req)
	if err != nil {
		return nil, err
	}
	resp, err := c.do(ctx, http.MethodPost, c.baseURL+"/v1/layers", bytes.NewReader(body), "application/json")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[fsLayerResponse](resp)
}

func (c *apiTestClient) ListFSLayers(ctx context.Context) ([]fsLayerResponse, error) {
	resp, err := c.do(ctx, http.MethodGet, c.baseURL+"/v1/layers", nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	var out struct {
		Layers []fsLayerResponse `json:"layers"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("decode fs layers: %w", err)
	}
	return out.Layers, nil
}

func (c *apiTestClient) ForkFSLayer(ctx context.Context, parentRef string, req fsLayerForkRequest) (*fsLayerResponse, error) {
	body, err := json.Marshal(req)
	if err != nil {
		return nil, err
	}
	u := c.baseURL + "/v1/layers/" + url.PathEscape(parentRef) + "/fork"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader(body), "application/json")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[fsLayerResponse](resp)
}

func (c *apiTestClient) GetFSLayerChain(ctx context.Context, layerRef string) ([]map[string]any, error) {
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/chain"
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	var out struct {
		Chain []map[string]any `json:"chain"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("decode fs layer chain: %w", err)
	}
	return out.Chain, nil
}

func (c *apiTestClient) DeleteFSLayer(ctx context.Context, layerRef string, cascade bool) error {
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef)
	if cascade {
		u += "?cascade=1"
	}
	resp, err := c.do(ctx, http.MethodDelete, u, nil, "")
	if err != nil {
		return err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return apiReadError(resp)
	}
	return nil
}

func (c *apiTestClient) GetFSLayer(ctx context.Context, layerRef string) (*fsLayerResponse, error) {
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef)
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[fsLayerResponse](resp)
}

func (c *apiTestClient) UpsertFSLayerEntry(ctx context.Context, layerRef string, req fsLayerEntryRequest) (*fsLayerEntryResponse, error) {
	body, err := json.Marshal(req)
	if err != nil {
		return nil, err
	}
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/entries"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader(body), "application/json")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[fsLayerEntryResponse](resp)
}

func (c *apiTestClient) UploadFSLayerFile(ctx context.Context, layerRef, path string, body io.Reader, size int64, baseRevision int64, mode uint32, hasMode bool) (*fsLayerEntryResponse, error) {
	q := url.Values{}
	q.Set("path", path)
	q.Set("size", strconv.FormatInt(size, 10))
	if baseRevision > 0 {
		q.Set("base_revision", strconv.FormatInt(baseRevision, 10))
	}
	if hasMode {
		q.Set("mode", strconv.FormatUint(uint64(mode&0o777), 8))
	}
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/objects?" + q.Encode()
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, body)
	if err != nil {
		return nil, err
	}
	req.ContentLength = size
	req.Header.Set("Content-Type", "application/octet-stream")
	resp, err := c.hc.Do(req)
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[fsLayerEntryResponse](resp)
}

func (c *apiTestClient) GetFSLayerEntry(ctx context.Context, layerRef, path string) (*fsLayerEntryResponse, error) {
	q := url.Values{}
	q.Set("path", path)
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/entries?" + q.Encode()
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[fsLayerEntryResponse](resp)
}

func (c *apiTestClient) ReadFSLayerFile(ctx context.Context, layerRef, path string, maxSeq *int64) ([]byte, error) {
	q := url.Values{}
	q.Set("path", path)
	if maxSeq != nil {
		q.Set("max_seq", strconv.FormatInt(*maxSeq, 10))
	}
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/objects?" + q.Encode()
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return io.ReadAll(resp.Body)
}

func (c *apiTestClient) InitiateFSLayerUpload(ctx context.Context, layerRef, path string, totalSize, baseRevision int64, mode uint32) (*backend.UploadPlanV2, error) {
	body, err := json.Marshal(map[string]any{
		"path":          path,
		"total_size":    totalSize,
		"base_revision": baseRevision,
		"mode":          mode,
	})
	if err != nil {
		return nil, err
	}
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/uploads/initiate"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader(body), "application/json")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode != http.StatusAccepted {
		return nil, apiReadError(resp)
	}
	return decodeJSON[backend.UploadPlanV2](resp)
}

func (c *apiTestClient) PresignFSLayerUploadPart(ctx context.Context, layerRef, uploadID string, partNumber int) (*s3client.UploadPartURL, error) {
	body, err := json.Marshal(map[string]int{"part_number": partNumber})
	if err != nil {
		return nil, err
	}
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/uploads/" + url.PathEscape(uploadID) + "/presign"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader(body), "application/json")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[s3client.UploadPartURL](resp)
}

func (c *apiTestClient) PresignFSLayerUploadParts(ctx context.Context, layerRef, uploadID string, parts []backend.PresignPartEntry) ([]s3client.UploadPartURL, error) {
	body, err := json.Marshal(map[string]any{"parts": parts})
	if err != nil {
		return nil, err
	}
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/uploads/" + url.PathEscape(uploadID) + "/presign-batch"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader(body), "application/json")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	var out struct {
		Parts []s3client.UploadPartURL `json:"parts"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("decode layer upload parts: %w", err)
	}
	return out.Parts, nil
}

func (c *apiTestClient) CompleteFSLayerUpload(ctx context.Context, layerRef, uploadID string, parts []backend.CompletePart, checksumSHA256 string) (*fsLayerEntryResponse, error) {
	body, err := json.Marshal(map[string]any{"parts": parts, "checksum_sha256": checksumSHA256})
	if err != nil {
		return nil, err
	}
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/uploads/" + url.PathEscape(uploadID) + "/complete"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader(body), "application/json")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[fsLayerEntryResponse](resp)
}

func (c *apiTestClient) AbortFSLayerUpload(ctx context.Context, layerRef, uploadID string) error {
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/uploads/" + url.PathEscape(uploadID) + "/abort"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader([]byte("{}")), "application/json")
	if err != nil {
		return err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return apiReadError(resp)
	}
	return nil
}

func (c *apiTestClient) CheckpointFSLayer(ctx context.Context, layerRef string, req fsLayerCheckpointRequest) (*fsLayerCheckpointResponse, error) {
	body, err := json.Marshal(req)
	if err != nil {
		return nil, err
	}
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/checkpoints"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader(body), "application/json")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[fsLayerCheckpointResponse](resp)
}

func (c *apiTestClient) GetFSLayerCheckpoint(ctx context.Context, checkpointID string) (*fsLayerCheckpointResponse, error) {
	u := c.baseURL + "/v1/layer-checkpoints/" + url.PathEscape(checkpointID)
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[fsLayerCheckpointResponse](resp)
}

func (c *apiTestClient) CommitFSLayer(ctx context.Context, layerRef string) (*fsLayerCommitResponse, error) {
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/commit"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader([]byte("{}")), "application/json")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		if resp.StatusCode == http.StatusConflict {
			body, readErr := io.ReadAll(resp.Body)
			if readErr != nil {
				return nil, &apiStatusError{StatusCode: resp.StatusCode, Message: "read fs layer commit conflict body"}
			}
			var out fsLayerCommitResponse
			if err := json.Unmarshal(body, &out); err == nil && (out.Status != "" || len(out.Conflicts) > 0) {
				return &out, &apiStatusError{StatusCode: resp.StatusCode, Message: "fs layer commit conflict"}
			}
			return nil, &apiStatusError{StatusCode: resp.StatusCode, Message: strings.TrimSpace(string(body))}
		}
		return nil, apiReadError(resp)
	}
	return decodeJSON[fsLayerCommitResponse](resp)
}

func (c *apiTestClient) RollbackFSLayer(ctx context.Context, layerRef string) error {
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/rollback"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader([]byte("{}")), "application/json")
	if err != nil {
		return err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return apiReadError(resp)
	}
	return nil
}

func (c *apiTestClient) fetchFSLayerDiff(ctx context.Context, layerRef string, replay bool) ([]fsLayerEntryResponse, error) {
	u := c.baseURL + "/v1/layers/" + url.PathEscape(layerRef) + "/diff"
	if replay {
		u += "?replay=1"
	}
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	var out struct {
		Entries []fsLayerEntryResponse `json:"entries"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("decode fs layer diff: %w", err)
	}
	return out.Entries, nil
}

func (c *apiTestClient) DiffFSLayer(ctx context.Context, layerRef string) ([]fsLayerEntryResponse, error) {
	return c.fetchFSLayerDiff(ctx, layerRef, false)
}

func (c *apiTestClient) ReplayFSLayer(ctx context.Context, layerRef string) ([]fsLayerEntryResponse, error) {
	return c.fetchFSLayerDiff(ctx, layerRef, true)
}

func (c *apiTestClient) GrepWithLayer(ctx context.Context, query, pathPrefix string, limit int, layerRef string) ([]apiSearchResult, error) {
	u := c.baseURL + "/v1/fs" + pathPrefix + "?grep=" + url.QueryEscape(query)
	if limit > 0 {
		u += "&limit=" + strconv.Itoa(limit)
	}
	if strings.TrimSpace(layerRef) != "" {
		u += "&layer=" + url.QueryEscape(strings.TrimSpace(layerRef))
	}
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	var results []apiSearchResult
	if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
		return nil, fmt.Errorf("decode grep results: %w", err)
	}
	return results, nil
}

func (c *apiTestClient) UpsertGitWorkspace(ctx context.Context, req gitWorkspaceRequest) (*gitWorkspaceResponse, error) {
	body, err := json.Marshal(req)
	if err != nil {
		return nil, err
	}
	resp, err := c.do(ctx, http.MethodPost, c.baseURL+"/v1/git-workspaces", bytes.NewReader(body), "application/json")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[gitWorkspaceResponse](resp)
}

func (c *apiTestClient) DeleteGitWorkspace(ctx context.Context, workspaceID string) error {
	if strings.TrimSpace(workspaceID) == "" {
		return fmt.Errorf("workspaceID must not be empty")
	}
	u := c.baseURL + "/v1/git-workspaces/" + url.PathEscape(workspaceID)
	resp, err := c.do(ctx, http.MethodDelete, u, nil, "")
	if err != nil {
		return err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return apiReadError(resp)
	}
	return nil
}

func (c *apiTestClient) GetGitWorkspace(ctx context.Context, workspaceID string) (*gitWorkspaceResponse, error) {
	if strings.TrimSpace(workspaceID) == "" {
		return nil, fmt.Errorf("workspaceID must not be empty")
	}
	u := c.baseURL + "/v1/git-workspaces/" + url.PathEscape(workspaceID)
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[gitWorkspaceResponse](resp)
}

func (c *apiTestClient) PutGitOverlayEntry(ctx context.Context, workspaceID string, req gitOverlayEntryRequest) (*gitOverlayEntryResponse, error) {
	body, err := json.Marshal(req)
	if err != nil {
		return nil, err
	}
	u := c.baseURL + "/v1/git-workspaces/" + url.PathEscape(workspaceID) + "/overlay"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader(body), "application/json")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[gitOverlayEntryResponse](resp)
}

func (c *apiTestClient) GetGitOverlayEntry(ctx context.Context, workspaceID, path string) (*gitOverlayEntryResponse, error) {
	u := c.baseURL + "/v1/git-workspaces/" + url.PathEscape(workspaceID) + "/overlay?path=" + url.QueryEscape(path)
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[gitOverlayEntryResponse](resp)
}

func (c *apiTestClient) ListGitOverlayEntries(ctx context.Context, workspaceID string) ([]gitOverlayEntryResponse, error) {
	u := c.baseURL + "/v1/git-workspaces/" + url.PathEscape(workspaceID) + "/overlay"
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	var out struct {
		Entries []gitOverlayEntryResponse `json:"entries"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("decode git overlay entries: %w", err)
	}
	return out.Entries, nil
}

func (c *apiTestClient) ReplaceGitTree(ctx context.Context, workspaceID string, req gitTreeReplaceRequest) error {
	body, err := json.Marshal(req)
	if err != nil {
		return err
	}
	u := c.baseURL + "/v1/git-workspaces/" + url.PathEscape(workspaceID) + "/tree"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader(body), "application/json")
	if err != nil {
		return err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return apiReadError(resp)
	}
	return nil
}

func (c *apiTestClient) ListGitTree(ctx context.Context, workspaceID, commitSHA string) ([]gitTreeNodeResponse, error) {
	u := c.baseURL + "/v1/git-workspaces/" + url.PathEscape(workspaceID) + "/tree?commit_sha=" + url.QueryEscape(commitSHA)
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	var out struct {
		Nodes []gitTreeNodeResponse `json:"nodes"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("decode git tree nodes: %w", err)
	}
	return out.Nodes, nil
}

func (c *apiTestClient) UpsertGitState(ctx context.Context, workspaceID string, req gitStateRequest) (*gitStateResponse, error) {
	body, err := json.Marshal(req)
	if err != nil {
		return nil, err
	}
	u := c.baseURL + "/v1/git-workspaces/" + url.PathEscape(workspaceID) + "/git-state"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader(body), "application/json")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[gitStateResponse](resp)
}

func (c *apiTestClient) GetGitState(ctx context.Context, workspaceID string) (*gitStateResponse, error) {
	u := c.baseURL + "/v1/git-workspaces/" + url.PathEscape(workspaceID) + "/git-state"
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[gitStateResponse](resp)
}

func (c *apiTestClient) PutGitObjectPack(ctx context.Context, workspaceID string, req gitObjectPackRequest) (*gitObjectPackResponse, error) {
	body, err := json.Marshal(req)
	if err != nil {
		return nil, err
	}
	u := c.baseURL + "/v1/git-workspaces/" + url.PathEscape(workspaceID) + "/object-packs"
	resp, err := c.do(ctx, http.MethodPost, u, bytes.NewReader(body), "application/json")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[gitObjectPackResponse](resp)
}

func (c *apiTestClient) ListGitObjectPacks(ctx context.Context, workspaceID string) ([]gitObjectPackResponse, error) {
	u := c.baseURL + "/v1/git-workspaces/" + url.PathEscape(workspaceID) + "/object-packs"
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	var out struct {
		Packs []gitObjectPackResponse `json:"packs"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("decode git object packs: %w", err)
	}
	return out.Packs, nil
}

func (c *apiTestClient) GetGitObjectPack(ctx context.Context, workspaceID, packID string) (*gitObjectPackResponse, error) {
	u := c.baseURL + "/v1/git-workspaces/" + url.PathEscape(workspaceID) + "/object-packs/" + url.PathEscape(packID)
	resp, err := c.do(ctx, http.MethodGet, u, nil, "")
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode >= 300 {
		return nil, apiReadError(resp)
	}
	return decodeJSON[gitObjectPackResponse](resp)
}
