// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package backend

import (
	"bytes"
	"context"
	"encoding/base64"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"

	"code.gitea.io/gitea/modules/json"
	"code.gitea.io/gitea/modules/lfs"
	"code.gitea.io/gitea/modules/setting"

	"github.com/charmbracelet/git-lfs-transfer/transfer"
)

// Version is the git-lfs-transfer protocol version number.
const Version = "1"

// Capabilities is a list of Git LFS capabilities supported by this package.
var Capabilities = []string{
	"version=" + Version,
	"locking",
}

var _ transfer.Backend = &GiteaBackend{}

// GiteaBackend is an adapter between git-lfs-transfer library and Gitea's internal LFS API
type GiteaBackend struct {
	ctx          context.Context
	server       *url.URL
	op           string
	authToken    string
	internalAuth string
	logger       transfer.Logger
}

func New(ctx context.Context, repo, op, token string, logger transfer.Logger) (transfer.Backend, error) {
	// runServ guarantees repo will be in form [owner]/[name].git
	server, err := url.Parse(setting.LocalURL)
	if err != nil {
		return nil, err
	}
	server = server.JoinPath("api/internal/repo", repo, "info/lfs")
	return &GiteaBackend{ctx: ctx, server: server, op: op, authToken: token, internalAuth: fmt.Sprintf("Bearer %s", setting.InternalToken), logger: logger}, nil
}

// Batch implements transfer.Backend
func (g *GiteaBackend) Batch(_ string, pointers []transfer.BatchItem, args transfer.Args) ([]transfer.BatchItem, error) {
	reqBody := lfs.BatchRequest{Operation: g.op}
	if transfer, ok := args[argTransfer]; ok {
		reqBody.Transfers = []string{transfer}
	}
	if ref, ok := args[argRefname]; ok {
		reqBody.Ref = &lfs.Reference{Name: ref}
	}
	reqBody.Objects = make([]lfs.Pointer, len(pointers))
	for i := range pointers {
		reqBody.Objects[i].Oid = pointers[i].Oid
		reqBody.Objects[i].Size = pointers[i].Size
	}

	bodyBytes, err := json.Marshal(reqBody)
	if err != nil {
		g.logger.Log("json marshal error", err)
		return nil, err
	}
	url := g.server.JoinPath("objects/batch").String()
	headers := map[string]string{
		headerAuthorization:     g.authToken,
		headerGiteaInternalAuth: g.internalAuth,
		headerAccept:            mimeGitLFS,
		headerContentType:       mimeGitLFS,
	}
	req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes)
	resp, err := req.Response()
	if err != nil {
		g.logger.Log("http request error", err)
		return nil, err
	}
	if resp.StatusCode != http.StatusOK {
		g.logger.Log("http statuscode error", resp.StatusCode, statusCodeToErr(resp.StatusCode))
		return nil, statusCodeToErr(resp.StatusCode)
	}
	defer resp.Body.Close()
	respBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		g.logger.Log("http read error", err)
		return nil, err
	}
	var respBody lfs.BatchResponse
	err = json.Unmarshal(respBytes, &respBody)
	if err != nil {
		g.logger.Log("json umarshal error", err)
		return nil, err
	}

	// rebuild slice, we can't rely on order in resp being the same as req
	pointers = pointers[:0]
	opNum := opMap[g.op]
	for _, obj := range respBody.Objects {
		pointer := transfer.Pointer{Oid: obj.Pointer.Oid, Size: obj.Pointer.Size}
		item := transfer.BatchItem{Pointer: pointer, Args: map[string]string{}}
		switch opNum {
		case opDownload:
			if action, ok := obj.Actions[actionDownload]; ok {
				item.Present = true
				idMap := obj.Actions
				idMapBytes, err := json.Marshal(idMap)
				if err != nil {
					g.logger.Log("json marshal error", err)
					return nil, err
				}
				idMapStr := base64.StdEncoding.EncodeToString(idMapBytes)
				item.Args[argID] = idMapStr
				if authHeader, ok := action.Header[headerAuthorization]; ok {
					authHeaderB64 := base64.StdEncoding.EncodeToString([]byte(authHeader))
					item.Args[argToken] = authHeaderB64
				}
				if action.ExpiresAt != nil {
					item.Args[argExpiresAt] = action.ExpiresAt.String()
				}
			} else {
				// must be an error, but the SSH protocol can't propagate individual errors
				g.logger.Log("object not found", obj.Pointer.Oid, obj.Pointer.Size)
				item.Present = false
			}
		case opUpload:
			if action, ok := obj.Actions[actionUpload]; ok {
				item.Present = false
				idMap := obj.Actions
				idMapBytes, err := json.Marshal(idMap)
				if err != nil {
					g.logger.Log("json marshal error", err)
					return nil, err
				}
				idMapStr := base64.StdEncoding.EncodeToString(idMapBytes)
				item.Args[argID] = idMapStr
				if authHeader, ok := action.Header[headerAuthorization]; ok {
					authHeaderB64 := base64.StdEncoding.EncodeToString([]byte(authHeader))
					item.Args[argToken] = authHeaderB64
				}
				if action.ExpiresAt != nil {
					item.Args[argExpiresAt] = action.ExpiresAt.String()
				}
			} else {
				item.Present = true
			}
		}
		pointers = append(pointers, item)
	}
	return pointers, nil
}

// Download implements transfer.Backend. The returned reader must be closed by the
// caller.
func (g *GiteaBackend) Download(oid string, args transfer.Args) (io.ReadCloser, int64, error) {
	idMapStr, exists := args[argID]
	if !exists {
		return nil, 0, ErrMissingID
	}
	idMapBytes, err := base64.StdEncoding.DecodeString(idMapStr)
	if err != nil {
		g.logger.Log("base64 decode error", err)
		return nil, 0, transfer.ErrCorruptData
	}
	idMap := map[string]*lfs.Link{}
	err = json.Unmarshal(idMapBytes, &idMap)
	if err != nil {
		g.logger.Log("json unmarshal error", err)
		return nil, 0, transfer.ErrCorruptData
	}
	action, exists := idMap[actionDownload]
	if !exists {
		g.logger.Log("argument id incorrect")
		return nil, 0, transfer.ErrCorruptData
	}
	url := action.Href
	headers := map[string]string{
		headerAuthorization:     g.authToken,
		headerGiteaInternalAuth: g.internalAuth,
		headerAccept:            mimeOctetStream,
	}
	req := newInternalRequest(g.ctx, url, http.MethodGet, headers, nil)
	resp, err := req.Response()
	if err != nil {
		return nil, 0, err
	}
	if resp.StatusCode != http.StatusOK {
		return nil, 0, statusCodeToErr(resp.StatusCode)
	}
	defer resp.Body.Close()
	respBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, 0, err
	}
	respSize := int64(len(respBytes))
	respBuf := io.NopCloser(bytes.NewBuffer(respBytes))
	return respBuf, respSize, nil
}

// StartUpload implements transfer.Backend.
func (g *GiteaBackend) Upload(oid string, size int64, r io.Reader, args transfer.Args) error {
	idMapStr, exists := args[argID]
	if !exists {
		return ErrMissingID
	}
	idMapBytes, err := base64.StdEncoding.DecodeString(idMapStr)
	if err != nil {
		g.logger.Log("base64 decode error", err)
		return transfer.ErrCorruptData
	}
	idMap := map[string]*lfs.Link{}
	err = json.Unmarshal(idMapBytes, &idMap)
	if err != nil {
		g.logger.Log("json unmarshal error", err)
		return transfer.ErrCorruptData
	}
	action, exists := idMap[actionUpload]
	if !exists {
		g.logger.Log("argument id incorrect")
		return transfer.ErrCorruptData
	}
	url := action.Href
	headers := map[string]string{
		headerAuthorization:     g.authToken,
		headerGiteaInternalAuth: g.internalAuth,
		headerContentType:       mimeOctetStream,
		headerContentLength:     strconv.FormatInt(size, 10),
	}
	reqBytes, err := io.ReadAll(r)
	if err != nil {
		return err
	}
	req := newInternalRequest(g.ctx, url, http.MethodPut, headers, reqBytes)
	resp, err := req.Response()
	if err != nil {
		return err
	}
	if resp.StatusCode != http.StatusOK {
		return statusCodeToErr(resp.StatusCode)
	}
	return nil
}

// Verify implements transfer.Backend.
func (g *GiteaBackend) Verify(oid string, size int64, args transfer.Args) (transfer.Status, error) {
	reqBody := lfs.Pointer{Oid: oid, Size: size}

	bodyBytes, err := json.Marshal(reqBody)
	if err != nil {
		return transfer.NewStatus(transfer.StatusInternalServerError), err
	}
	idMapStr, exists := args[argID]
	if !exists {
		return transfer.NewStatus(transfer.StatusBadRequest, "missing argument: id"), ErrMissingID
	}
	idMapBytes, err := base64.StdEncoding.DecodeString(idMapStr)
	if err != nil {
		g.logger.Log("base64 decode error", err)
		return transfer.NewStatus(transfer.StatusBadRequest, "corrupt argument: id"), transfer.ErrCorruptData
	}
	idMap := map[string]*lfs.Link{}
	err = json.Unmarshal(idMapBytes, &idMap)
	if err != nil {
		g.logger.Log("json unmarshal error", err)
		return transfer.NewStatus(transfer.StatusBadRequest, "corrupt argument: id"), transfer.ErrCorruptData
	}
	action, exists := idMap[actionVerify]
	if !exists {
		// the server sent no verify action
		return transfer.SuccessStatus(), nil
	}
	url := action.Href
	headers := map[string]string{
		headerAuthorization:     g.authToken,
		headerGiteaInternalAuth: g.internalAuth,
		headerAccept:            mimeGitLFS,
		headerContentType:       mimeGitLFS,
	}
	req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes)
	resp, err := req.Response()
	if err != nil {
		return transfer.NewStatus(transfer.StatusInternalServerError), err
	}
	if resp.StatusCode != http.StatusOK {
		return transfer.NewStatus(uint32(resp.StatusCode), http.StatusText(resp.StatusCode)), statusCodeToErr(resp.StatusCode)
	}
	return transfer.SuccessStatus(), nil
}

// LockBackend implements transfer.Backend.
func (g *GiteaBackend) LockBackend(_ transfer.Args) transfer.LockBackend {
	return newGiteaLockBackend(g)
}
