package xrpc

import (
	"bytes"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"os"
	"path/filepath"
	"strings"

	securejoin "github.com/cyphar/filepath-securejoin"
	"github.com/go-chi/chi/v5"
	"tangled.org/core/api/tangled"
	"tangled.org/core/idresolver"
	"tangled.org/core/knotserver/config"
	"tangled.org/core/knotserver/db"
	"tangled.org/core/knotserver/sandbox"
	"tangled.org/core/notifier"
	"tangled.org/core/rbac"
	"tangled.org/core/repoident"
	xrpcerr "tangled.org/core/xrpc/errors"
	"tangled.org/core/xrpc/serviceauth"
)

const ActorDid = serviceauth.ActorDid

type DidIngester interface {
	AddDid(did string)
	RemoveDid(did string)
}

type Xrpc struct {
	Config      *config.Config
	Db          *db.DB
	Ingester    DidIngester
	Enforcer    *rbac.Enforcer
	Logger      *slog.Logger
	Notifier    *notifier.Notifier
	Resolver    *idresolver.Resolver
	ServiceAuth *serviceauth.ServiceAuth
	Sandbox     sandbox.Backend
}

func (x *Xrpc) Router() http.Handler {
	r := chi.NewRouter()

	r.Group(func(r chi.Router) {
		r.Use(x.ServiceAuth.VerifyServiceAuth)

		r.Post("/"+tangled.GitKeepCommitNSID, x.KeepCommit)
		r.Post("/"+tangled.GitMergeCommitNSID, x.MergeCommit)
		r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
		r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch)
		r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo)
		r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo)
		r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
		r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync)
		r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef)
		r.Post("/"+tangled.RepoMergeNSID, x.Merge)
		r.Post("/"+tangled.KnotAddMemberNSID, x.AddMember)
		r.Post("/"+tangled.KnotRemoveMemberNSID, x.RemoveMember)
		r.Post("/"+tangled.RepoAddCollaboratorNSID, x.AddCollaborator)
		r.Post("/"+tangled.RepoRemoveCollaboratorNSID, x.RemoveCollaborator)
	})

	// merge check is an open endpoint
	//
	// TODO: should we constrain this more?
	// - we can calculate on PR submit/resubmit/gitRefUpdate etc.
	// - use ETags on clients to keep requests to a minimum
	r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck)
	r.Post("/"+tangled.GitMergeCheckNSID, x.GitMergeCheck)

	// repo query endpoints (no auth required)
	r.Get("/"+tangled.RepoTreeNSID, x.RepoTree)
	r.Get("/"+tangled.RepoLogNSID, x.RepoLog)
	r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches)
	r.Get("/"+tangled.RepoTagsNSID, x.RepoTags)
	r.Get("/"+tangled.RepoTagNSID, x.RepoTag)
	r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob)
	r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff)
	r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare)
	r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch)
	r.Get("/"+tangled.RepoDescribeRepoNSID, x.RepoDescribeRepo)
	r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch)
	r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive)
	r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages)
	r.Get("/"+tangled.RepoListCollaboratorsNSID, x.ListCollaborators)

	// knot query endpoints (no auth required)
	r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys)
	r.Get("/"+tangled.KnotListMembersNSID, x.ListMembers)
	r.Get("/"+tangled.KnotVersionNSID, x.Version)

	// service query endpoints (no auth required)
	r.Get("/"+tangled.OwnerNSID, x.Owner)

	return r
}

func (x *Xrpc) parseRepoParam(repo string) (string, error) {
	if repo == "" || !strings.HasPrefix(repo, "did:") {
		return "", xrpcerr.NewXrpcError(
			xrpcerr.WithTag("InvalidRequest"),
			xrpcerr.WithMessage("missing or invalid repo parameter, expected a repo DID"),
		)
	}

	if !strings.Contains(repo, "/") {
		repoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, repo)
		if err != nil {
			return "", xrpcerr.RepoNotFoundError
		}
		return repoPath, nil
	}

	parts := strings.SplitN(repo, "/", 2)
	ownerDid, repoName := parts[0], parts[1]

	repoDid, err := x.Db.GetRepoDid(ownerDid, repoName)
	if err == nil {
		repoPath, _, _, resolveErr := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, repoDid)
		if resolveErr == nil {
			return repoPath, nil
		}
	}

	repoPath, joinErr := securejoin.SecureJoin(x.Config.Repo.ScanPath, filepath.Join(ownerDid, repoName))
	if joinErr != nil {
		return "", xrpcerr.RepoNotFoundError
	}
	if _, statErr := os.Stat(repoPath); statErr != nil {
		return "", xrpcerr.RepoNotFoundError
	}
	return repoPath, nil
}

func (x *Xrpc) resolveRepoDID(repo *string, ownerDid, name string) (repoident.RepoDid, string, error) {
	raw, err := x.selectRepoDID(repo, ownerDid, name)
	if err != nil {
		return "", "", err
	}

	repoDid, err := repoident.NewRepoDid(raw)
	if err != nil {
		return "", "", err
	}

	repoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, repoDid.String())
	if err != nil {
		return "", "", err
	}
	return repoDid, repoPath, nil
}

func (x *Xrpc) selectRepoDID(repo *string, ownerDid, name string) (string, error) {
	if repo != nil && *repo != "" {
		return *repo, nil
	}
	return x.Db.GetRepoDid(ownerDid, name)
}

func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(e)
}

type limitWriter struct {
	buf     bytes.Buffer
	limit   int
	written int
}

var errResponseTooLarge = errors.New("response too large")

func (lw *limitWriter) Write(p []byte) (int, error) {
	if lw.written+len(p) > lw.limit {
		return 0, errResponseTooLarge
	}
	n, err := lw.buf.Write(p)
	lw.written += n
	return n, err
}

func (x *Xrpc) writeJson(w http.ResponseWriter, response any) {
	lw := &limitWriter{limit: x.Config.Server.MaxResponseKB * 1024}
	if err := json.NewEncoder(lw).Encode(response); err != nil {
		if errors.Is(err, errResponseTooLarge) {
			writeError(w, xrpcerr.RequestTooLargeError, http.StatusRequestEntityTooLarge)
		} else {
			writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
		}
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.Write(lw.buf.Bytes())
}

func writeJson(w http.ResponseWriter, status int, response any) error {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(response); err != nil {
		return err
	}
	return nil
}
